Fix analytics data-loss bugs and clean up the admin dashboard; v2.2.4
Publish Container / build (push) Successful in 16m3s

- Self-heal consent desync: if the server's consent cookie and the
  client's localStorage flag fall out of sync, the client now notices
  and stops silently sending events into a void.
- Stop double-counting hard page loads in hit/visitor stats.
- Flush pending disk writes on SIGTERM/SIGINT so redeploys don't drop
  the last few events.
- Log rate-limiter drops once per window (diagnoses proxy misconfig).
- Widen the bot-detection allowlist to avoid flagging real browsers
  (Chrome-iOS, Samsung Browser, etc.) as bots.
- Stop merging distinct visitors who share an IP when a real session
  cookie is already present — only fall back to IP correlation when no
  cookie was sent at all.
- Include analyticsEvents/episodePlays in backup snapshots and restore
  them, so a restore no longer wipes engagement analytics.
- Regroup admin dashboard KPI cards by time scope (range vs all-time)
  and remove a duplicated Top Countries/States block.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5 committed 2026-09-24 14:06:02 -04:00
1 parent 8496e227b3
commit d1a68754a1
9 files changed
+183 -45

No files matched your search

+12 -3
View File
@@ -15,7 +15,7 @@ import { EpisodeAudioPlayer } from './components/EpisodeAudioPlayer'
import { NuggetsHostPortal } from './components/NuggetsHostPortal'
import { BIBLE_BOOKS } from '../shared/bible-books.js'
import './App.css'
import { sendEvent, useScrollDepthTracking, useTimeOnPage, useUTMCapture, useLinkClickTracking } from './analytics'
import { sendEvent, useScrollDepthTracking, useTimeOnPage, useUTMCapture, useLinkClickTracking, checkAnalyticsConsentResponse } from './analytics'
const CONSENT_KEY = 'vbn_analytics_consent_choice'
const HEADLINER_WIDGET_ID = 'WI_cmou3b4q7000701p0o9qmmcfj'
@@ -28,7 +28,16 @@ function ScrollToTop() {
function usePageTracking() {
const location = useLocation()
const isFirstRun = useRef(true)
useEffect(() => {
// The initial hard page load is already counted server-side by the
// hit-counting middleware in server.js (it sees the raw HTML request).
// Only client-side route changes (no new HTTP request) need reporting
// here — otherwise every fresh visit/refresh gets double-counted.
if (isFirstRun.current) {
isFirstRun.current = false
return
}
if (localStorage.getItem(CONSENT_KEY) !== 'accepted') return
const referrer = document.referrer || ''
const controller = new AbortController()
@@ -39,7 +48,7 @@ function usePageTracking() {
body: JSON.stringify({ path: location.pathname, referrer }),
keepalive: true,
signal: controller.signal,
}).catch(() => {}).finally(() => clearTimeout(timeout))
}).then(checkAnalyticsConsentResponse).catch(() => {}).finally(() => clearTimeout(timeout))
}, [location.pathname])
}
@@ -1794,7 +1803,7 @@ function LatestEpisodePlayer({ content }: { content: SiteContent }) {
listenStartRef.current = Date.now()
if (!playTrackedRef.current && title) {
playTrackedRef.current = true
fetch('/api/analytics/play', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title }) }).catch(() => {})
fetch('/api/analytics/play', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title }) }).then(checkAnalyticsConsentResponse).catch(() => {})
}
}).catch(() => {})
}
+14 -1
View File
@@ -24,6 +24,19 @@ function hasConsent(): boolean {
} catch { /* sessionStorage unavailable */ }
})()
// The client gates on a localStorage flag; the server gates on a cookie.
// If the cookie is lost (cleared separately, dropped by the browser on a
// plain-HTTP deployment, etc.) while localStorage still says "accepted",
// the client would otherwise keep believing tracking is on and silently
// lose every event forever. Self-heal by clearing the stale flag so the
// consent banner reappears instead of leaking data indefinitely.
export async function checkAnalyticsConsentResponse(res: Response): Promise<void> {
try {
const data = await res.json()
if (data?.reason === 'no-consent') localStorage.removeItem(CONSENT_KEY)
} catch { /* ignore */ }
}
export function sendEvent(type: string, data: Record<string, unknown>): void {
if (!hasConsent()) return
fetch('/api/analytics/event', {
@@ -31,7 +44,7 @@ export function sendEvent(type: string, data: Record<string, unknown>): void {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type, ...data }),
keepalive: true,
}).catch(() => {})
}).then(checkAnalyticsConsentResponse).catch(() => {})
}
// --- Scroll depth ---
+34 -28
View File
@@ -273,28 +273,38 @@ export function AnalyticsPanel({
</div>
)}
{/* KPI Grid */}
<div className="admin-stats-grid">
{/* KPI Grid — split into two clearly-labeled groups so a number from
the selected range is never mistaken for an all-time total, or
vice versa. Previously all six cards sat in one grid with three
different implicit time windows (selected range, a hardcoded last
30 days, and all-time), which read as contradictory when e.g. "7
days" was selected but a "Last 30 Days" card sat right next to it. */}
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>{rangeLabel}</h3>
<div className="admin-stats-grid" style={{ marginBottom: '1.5rem' }}>
<article>
<h3>Real Hits</h3>
<p>{realHits.toLocaleString()}</p>
<small>{rangeLabel}</small>
<small>Human traffic</small>
</article>
<article>
<h3>Bot Traffic</h3>
<p>{botHits.toLocaleString()}</p>
<small>{rangeTotalHits > 0 ? `${((botHits / rangeTotalHits) * 100).toFixed(1)}% of total` : '0% of total'}</small>
</article>
<article>
<h3>Total Hits</h3>
<p>{rangeTotalHits.toLocaleString()}</p>
<small>Real + bot</small>
</article>
</div>
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>All-Time</h3>
<div className="admin-stats-grid">
<article>
<h3>All-Time Real Hits</h3>
<p>{((stats as AdminStats & { allTimeRealHits?: number }).allTimeRealHits ?? stats.totalHits).toLocaleString()}</p>
<small>Since {formatDate(stats.firstHitAt)}</small>
</article>
<article>
<h3>Last 30 Days</h3>
<p>{last30DaysRealTotal.toLocaleString()}</p>
<small>Real hits · {last30DaysBotTotal.toLocaleString()} bots</small>
</article>
<article>
<h3>Unique Visitors</h3>
<p>{stats.visitors.uniqueVisitors.toLocaleString()}</p>
@@ -305,6 +315,11 @@ export function AnalyticsPanel({
<p>{stats.visitors.totalVisits > 0 ? `${Math.round((stats.visitors.returningVisits / stats.visitors.totalVisits) * 100)}%` : '0%'}</p>
<small>{stats.visitors.returningVisits.toLocaleString()} return visits</small>
</article>
<article>
<h3>Last 30 Days</h3>
<p>{last30DaysRealTotal.toLocaleString()}</p>
<small>Real hits · {last30DaysBotTotal.toLocaleString()} bots</small>
</article>
</div>
{/* Chart Tabs */}
@@ -409,6 +424,14 @@ export function AnalyticsPanel({
))}
</ul>
</div>
<div>
<h3>Top States</h3>
<ul>
{stats.visitors.topStates.slice(0, 10).map((item: { name: string; hits: number }) => (
<li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>
))}
</ul>
</div>
<div>
<h3>Top Countries</h3>
<ul>
@@ -451,29 +474,12 @@ export function AnalyticsPanel({
<div className="admin-stats-grid admin-stats-grid--visitor">
<article><h3>Total Visits</h3><p>{stats.visitors.totalVisits.toLocaleString()}</p><small>All time</small></article>
<article><h3>Unique Visitors</h3><p>{stats.visitors.uniqueVisitors.toLocaleString()}</p><small>All time</small></article>
<article><h3>Returning Visits</h3><p>{stats.visitors.returningVisits.toLocaleString()}</p></article>
<article><h3>First Visit</h3><p>{formatDate(stats.visitors.firstVisitAt)}</p></article>
</div>
<div className="admin-stats-lists">
<div>
<h3>Top Countries</h3>
<ul>
{stats.visitors.topCountries.map((item: { name: string; hits: number }) => (
<li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>
))}
</ul>
</div>
<div>
<h3>Top States</h3>
<ul>
{stats.visitors.topStates.map((item: { name: string; hits: number }) => (
<li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>
))}
</ul>
</div>
</div>
{/* Top Countries/States/Cities live in the Geographic tab above, alongside
the map — not repeated here to avoid showing the same numbers twice
on one page. */}
<div className="admin-visits-table-wrap">
<h3>Recent Visitor Log</h3>
+2 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { sendEvent } from '../analytics'
import { sendEvent, checkAnalyticsConsentResponse } from '../analytics'
interface EpisodeAudioPlayerProps {
src: string
@@ -101,7 +101,7 @@ export function EpisodeAudioPlayer({ src, title, size = 'full', spotifyUrl }: Ep
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
}).catch(() => {})
}).then(checkAnalyticsConsentResponse).catch(() => {})
}
}).catch(() => {})
}