Fix analytics data-loss bugs and clean up the admin dashboard; v2.2.4
Publish Container / build (push) Successful in 16m3s
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:
1 parent
8496e227b3
commit
d1a68754a1
9 files changed
+183
-45
No files matched your search
+12
-3
@@ -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
@@ -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 ---
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {})
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user