From e9d9dd0d505826741324e0443b1e5a82c70ecaf0 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Thu, 15 Oct 2026 11:38:26 -0400 Subject: [PATCH] Analytics: unique visitors this week + probed-URLs list; Scripture Index: most-studied strip + print; admin warning for unparseable passage references; v2.44.0 Co-Authored-By: Claude Sonnet 5.5 --- package-lock.json | 4 ++-- package.json | 2 +- server.js | 2 +- server/config.js | 1 + server/data.js | 2 ++ server/routes/analytics.js | 7 +++++++ server/study-helpers.js | 17 ++++++++++++++++ shared/bible-books.js | 33 ++++++++++++++++++++++++++++++- src/AdminPage.tsx | 9 ++++++++- src/App.css | 21 ++++++++++++++++++++ src/App.tsx | 25 +++++++++++++++++++++-- src/components/AnalyticsPanel.tsx | 19 +++++++++++++++++- test/hit-paths.test.js | 10 ++++++++++ 13 files changed, 143 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index f034b71..7688609 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "siteforge", - "version": "2.43.1", + "version": "2.44.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "siteforge", - "version": "2.43.1", + "version": "2.44.0", "dependencies": { "@aws-sdk/client-s3": "^3.1135.0", "@dnd-kit/core": "^6.3.1", diff --git a/package.json b/package.json index b229a04..ddc925d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.43.1", + "version": "2.44.0", "type": "module", "scripts": { "dev": "vite", diff --git a/server.js b/server.js index d3a2503..233fad2 100644 --- a/server.js +++ b/server.js @@ -146,7 +146,7 @@ app.use((req, res, next) => { const ua = sanitizeUserAgent(req.get('user-agent')) const knownPath = isKnownSitePath(req.path) const botDetection = knownPath ? detectBot(ua) : { isBot: true, reason: 'unknown-path' } - recordHit(knownPath ? req.path : '(unknown)', botDetection.isBot, botDetection.reason) + recordHit(req.path, botDetection.isBot, botDetection.reason) queueHitStatsWrite() if (hasVisitorConsent(req) && !botDetection.isBot && !isValidAdminSession(req)) { try { diff --git a/server/config.js b/server/config.js index 36d879d..898dd64 100644 --- a/server/config.js +++ b/server/config.js @@ -172,6 +172,7 @@ export const EMPTY_HIT_STATS = { byPath: {}, byPathReal: {}, byPathBot: {}, + byPathUnknown: {}, byDay: {}, byDayReal: {}, byDayBot: {}, diff --git a/server/data.js b/server/data.js index 3203f22..586f604 100644 --- a/server/data.js +++ b/server/data.js @@ -167,6 +167,7 @@ export function loadHitStatsFromDisk() { byPath: parsed?.byPath && typeof parsed.byPath === 'object' ? parsed.byPath : {}, byPathReal: parsed?.byPathReal && typeof parsed.byPathReal === 'object' ? parsed.byPathReal : {}, byPathBot: parsed?.byPathBot && typeof parsed.byPathBot === 'object' ? parsed.byPathBot : {}, + byPathUnknown: parsed?.byPathUnknown && typeof parsed.byPathUnknown === 'object' ? parsed.byPathUnknown : {}, byDay: parsed?.byDay && typeof parsed.byDay === 'object' ? parsed.byDay : {}, byDayReal: parsed?.byDayReal && typeof parsed.byDayReal === 'object' ? parsed.byDayReal : {}, byDayBot: parsed?.byDayBot && typeof parsed.byDayBot === 'object' ? parsed.byDayBot : {}, @@ -1250,6 +1251,7 @@ function sanitizeLoadedHitStats(value) { byPath: value?.byPath && typeof value.byPath === 'object' ? value.byPath : {}, byPathReal: value?.byPathReal && typeof value.byPathReal === 'object' ? value.byPathReal : {}, byPathBot: value?.byPathBot && typeof value.byPathBot === 'object' ? value.byPathBot : {}, + byPathUnknown: value?.byPathUnknown && typeof value.byPathUnknown === 'object' ? value.byPathUnknown : {}, byDay: value?.byDay && typeof value.byDay === 'object' ? value.byDay : {}, byDayReal: value?.byDayReal && typeof value.byDayReal === 'object' ? value.byDayReal : {}, byDayBot: value?.byDayBot && typeof value.byDayBot === 'object' ? value.byDayBot : {}, diff --git a/server/routes/analytics.js b/server/routes/analytics.js index 47e561a..6f48b63 100644 --- a/server/routes/analytics.js +++ b/server/routes/analytics.js @@ -393,6 +393,13 @@ export function register(app) { firstHitAt: state.hitStats.firstHitAt, lastHitAt: state.hitStats.lastHitAt, topPaths, topPathsReal, topPathsBot, + topUnknownPaths: Object.entries(state.hitStats.byPathUnknown ?? {}) + .sort((a, b) => b[1] - a[1]).slice(0, 25).map(([path, hits]) => ({ path, hits })), + uniqueVisitors7d: new Set( + state.visitorStats.recentVisits + .filter(v => Date.now() - new Date(v.at).getTime() <= 7 * 24 * 60 * 60 * 1000) + .map(v => v.visitorId), + ).size, last7Days, last7DaysReal, last7DaysBot, last30DaysTotal, last30DaysRealTotal, last30DaysBotTotal, botReasons, diff --git a/server/study-helpers.js b/server/study-helpers.js index 47304c3..1a62ea0 100644 --- a/server/study-helpers.js +++ b/server/study-helpers.js @@ -484,6 +484,8 @@ const KNOWN_SITE_SEGMENTS = new Set([ 'privacy', 'terms', 'salvation', 'qr', ]) +const MAX_UNKNOWN_PATHS = 300 + export function isKnownSitePath(pathname) { const first = String(pathname ?? '/').split('/')[1] ?? '' return first === '' || KNOWN_SITE_SEGMENTS.has(first) @@ -593,6 +595,21 @@ export function recordHit(pathname, isBot = false, botReason = null) { state.hitStats.lastHitAt = nowIso state.hitStats.firstHitAt = state.hitStats.firstHitAt ?? nowIso + if (isBot && botReason === 'unknown-path') { + // Probe/scanner URLs: keep a bounded list for the admin view instead of one stats key per URL + const unknown = (state.hitStats.byPathUnknown ??= {}) + const key = safePath.slice(0, 120) + if (key in unknown || Object.keys(unknown).length < MAX_UNKNOWN_PATHS) unknown[key] = (unknown[key] ?? 0) + 1 + else unknown['(other)'] = (unknown['(other)'] ?? 0) + 1 + state.hitStats.botHits += 1 + state.hitStats.byPathBot['(unknown)'] = (state.hitStats.byPathBot['(unknown)'] ?? 0) + 1 + state.hitStats.byDayBot[dayKey] = (state.hitStats.byDayBot[dayKey] ?? 0) + 1 + state.hitStats.botReasons[botReason] = (state.hitStats.botReasons[botReason] ?? 0) + 1 + state.hitStats.byPath['(unknown)'] = (state.hitStats.byPath['(unknown)'] ?? 0) + 1 + state.hitStats.byDay[dayKey] = (state.hitStats.byDay[dayKey] ?? 0) + 1 + return + } + if (isBot) { state.hitStats.botHits += 1 state.hitStats.byPathBot[safePath] = (state.hitStats.byPathBot[safePath] ?? 0) + 1 diff --git a/shared/bible-books.js b/shared/bible-books.js index 052d9fc..cf82bc3 100644 --- a/shared/bible-books.js +++ b/shared/bible-books.js @@ -67,7 +67,7 @@ export const BIBLE_BOOKS = [ { id: 'TH2', name: '2 Thessalonians', aliases: ['2 thess'] }, { id: 'TI1', name: '1 Timothy', aliases: ['1 tim'] }, { id: 'TI2', name: '2 Timothy', aliases: ['2 tim'] }, - { id: 'TIT', name: 'Titus' }, + { id: 'TIT', name: 'Titus', aliases: ['tit'] }, { id: 'PHM', name: 'Philemon', aliases: ['philem'] }, { id: 'HEB', name: 'Hebrews', aliases: ['heb'] }, { id: 'JAM', name: 'James', aliases: ['jas'] }, @@ -85,3 +85,34 @@ export function bookNumberFor(usfmId) { const index = BIBLE_BOOKS.findIndex(b => b.id === usfmId) return index === -1 ? null : index + 1 } + +let referenceBookLookup = null + +/** + * Checks a passage reference the way the Scripture Index and passage fetch read it. + * `bookHint` is the study's Bible Book field (or slug) and is used when the reference + * has no book of its own ("1:1-2"). Returns null when it will work, otherwise a short + * message explaining what to change. + */ +export function checkPassageReference(reference, bookHint) { + if (!referenceBookLookup) { + referenceBookLookup = new Set() + for (const b of BIBLE_BOOKS) { + for (const key of [b.name.toLowerCase(), ...(b.aliases ?? [])]) { + referenceBookLookup.add(key) + referenceBookLookup.add(key.replace(/\s+/g, '')) + } + } + } + const clean = String(reference ?? '').replace(/[–—]/g, '-').replace(/(\d)[a-z]\b/gi, '$1').trim() + if (!clean) return null + const m = clean.match(/^(?:(.*?)\s+)?(\d+):(\d+)(?:-(?:(\d+):)?(\d+))?$/) + if (!m) return 'Use the form 1:1-2 (chapter:verse, optional range). It will be listed under "Other references" in the Scripture Index until fixed.' + const book = (m[1] ?? String(bookHint ?? '')).toLowerCase().replace(/\.\s*/g, ' ').trim() + if (!book || !referenceBookLookup.has(book)) { + return m[1] + ? `"${m[1]}" is not a recognized Bible book name.` + : 'Set this study\'s "Bible Book" field (e.g. titus) so this reference can be matched to a book.' + } + return null +} diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index 7ada038..f9ec03a 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -1,3 +1,4 @@ +import { checkPassageReference } from '../shared/bible-books.js' import DOMPurify from 'dompurify' import React, { useEffect, useRef, useState } from 'react' import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from '@dnd-kit/core' @@ -616,6 +617,8 @@ export interface AdminStats { topPaths: Array<{ path: string; hits: number }> topPathsReal: Array<{ path: string; hits: number }> topPathsBot: Array<{ path: string; hits: number }> + topUnknownPaths?: Array<{ path: string; hits: number }> + uniqueVisitors7d?: number last7Days: Array<{ day: string; hits: number }> last7DaysReal: Array<{ day: string; hits: number }> last7DaysBot: Array<{ day: string; hits: number }> @@ -4137,7 +4140,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
{thisWeekReal.toLocaleString()}
Real Visits (7 Days)
-
{thisWeekHits.toLocaleString()} total · {stats?.visitors?.uniqueVisitors?.toLocaleString() ?? '—'} unique all time
+
{thisWeekHits.toLocaleString()} total · {stats?.uniqueVisitors7d?.toLocaleString() ?? '—'} unique this week · {stats?.visitors?.uniqueVisitors?.toLocaleString() ?? '—'} all time (consented)
@@ -6301,6 +6304,10 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
updateStudySection(activeStudy.id, activeLesson.id, 'reference', e.target.value)} /> + {(() => { + const refProblem = checkPassageReference(activeLesson.reference, activeStudy.bibleBook?.trim() || activeStudy.slug) + return refProblem ?

⚠ {refProblem}

: null + })()}
diff --git a/src/App.css b/src/App.css index efd09f8..f985978 100644 --- a/src/App.css +++ b/src/App.css @@ -13718,6 +13718,27 @@ a.study-track-card:hover { .si-chip-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } .si-verse-btn { margin-left: auto; font: inherit; font-size: 0.8rem; background: none; border: 0; padding: 0; cursor: pointer; color: var(--brand-muted); text-decoration: underline; } .si-verse-btn:hover { color: var(--brand-gold); } +.si-top { margin-bottom: 1rem; padding: 0.9rem 1rem; background: var(--brand-black-3); border: 1px solid var(--brand-border); border-radius: 12px; } +.si-top-title { margin: 0 0 0.6rem; font-family: var(--brand-font-heading); font-size: 1.15rem; font-weight: 600; } +.si-top-list { display: flex; flex-wrap: wrap; gap: 0.5rem; } +.si-top-item { display: inline-flex; align-items: baseline; gap: 0.5rem; font: inherit; font-size: 0.88rem; padding: 0.35rem 0.8rem; border-radius: 999px; border: 1px solid var(--brand-border); background: transparent; color: var(--brand-warm-white); cursor: pointer; } +.si-top-item:hover { border-color: var(--brand-gold-dark); } +.si-top-ref { font-weight: 700; color: var(--brand-gold-light); } +.si-top-count { font-size: 0.75rem; color: var(--brand-muted); } +.si-print-title { display: none; } +@media print { + .site > *:not(.si-main) { display: none !important; } + .si-main { padding: 0; } + .si-main, .si-group, .si-top { background: #fff !important; color: #000 !important; } + .si-print-title { display: block; font-size: 1.6rem; margin: 0 0 1rem; color: #000; } + .si-toolbar, .si-books, .si-meta, .si-top, .si-verse-btn, .si-verse, .si-chev { display: none !important; } + .si-group { border: 0; border-bottom: 1px solid #999; break-inside: avoid-page; border-radius: 0; } + .si-group-head { padding: 0.5rem 0; color: #000; } + .si-ref { color: #000; } + .si-chip { border: 0; padding: 0; color: #000; } + .si-chip-kind { color: #444; } + .si-chip-label { white-space: normal; overflow: visible; } +} .si-verse { margin: 0.5rem 0 0; padding-left: 0.9rem; border-left: 2px solid var(--brand-gold-dark); font-size: 0.95rem; line-height: 1.65; font-style: italic; color: var(--brand-muted); } @media (max-width: 600px) { diff --git a/src/App.tsx b/src/App.tsx index de4561c..c4c800e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2735,7 +2735,7 @@ function ScriptureIndexPage({ content }: { content: SiteContent }) { // Merge entries that share the same reference (a study lesson and an episode can both // cover the same passage) into one row, then group rows by book in canonical Bible order. - const { bookGroups, otherRows } = useMemo(() => { + const { bookGroups, otherRows, topRows } = useMemo(() => { const rowMap = new Map() const order: string[] = [] for (const entry of entries) { @@ -2789,7 +2789,12 @@ function ScriptureIndexPage({ content }: { content: SiteContent }) { rows: groupsByBook.get(id)!.sort((a, b) => a.chapter - b.chapter || a.verseStart - b.verseStart), })) other.sort((a, b) => a.displayRef.localeCompare(b.displayRef)) - return { bookGroups: groups, otherRows: other } + // Passages covered by the most lessons/episodes (needs at least two sources to be worth highlighting) + const top = [...rowMap.values()] + .filter(r => r.bookId && r.sources.length >= 2) + .sort((a, b) => b.sources.length - a.sources.length || order.indexOf(a.key) - order.indexOf(b.key)) + .slice(0, 6) + return { bookGroups: groups, otherRows: other, topRows: top } }, [entries]) // Apply the search box and filters @@ -2886,6 +2891,7 @@ function ScriptureIndexPage({ content }: { content: SiteContent }) {
+

Scripture Index — {siteTitle}

{loading ? (

Loading…

) : !hasContent ? ( @@ -2915,6 +2921,20 @@ function ScriptureIndexPage({ content }: { content: SiteContent }) {
+ {!filtering && topRows.length > 0 && ( +
+

Most-studied passages

+
+ {topRows.map(r => ( + + ))} +
+
+ )} + {bookGroups.length > 1 && (
{bookGroups.map(g => ( @@ -2938,6 +2958,7 @@ function ScriptureIndexPage({ content }: { content: SiteContent }) { )} +
diff --git a/src/components/AnalyticsPanel.tsx b/src/components/AnalyticsPanel.tsx index afcf323..7572ffb 100644 --- a/src/components/AnalyticsPanel.tsx +++ b/src/components/AnalyticsPanel.tsx @@ -308,7 +308,7 @@ export function AnalyticsPanel({

Unique Visitors

{stats.visitors.uniqueVisitors.toLocaleString()}

- Tracked with consent + {stats.uniqueVisitors7d != null ? `${stats.uniqueVisitors7d.toLocaleString()} this week · ` : ''}Tracked with consent

Returning Rate

@@ -399,6 +399,23 @@ export function AnalyticsPanel({ )}
+
+
+

Probed URLs (not real pages)

+ {(stats.topUnknownPaths ?? []).length === 0 ? ( +

No probe traffic recorded yet. Requests for URLs that are not site pages (like /.env or /wp-login) are listed here and counted as bots.

+ ) : ( +
    + {(stats.topUnknownPaths ?? []).map(row => ( +
  • + {row.path} + {row.hits.toLocaleString()} +
  • + ))} +
+ )} +
+

Top Pages — Real vs Bot ({rangeLabel})

diff --git a/test/hit-paths.test.js b/test/hit-paths.test.js index 0efec7b..6f4e530 100644 --- a/test/hit-paths.test.js +++ b/test/hit-paths.test.js @@ -13,3 +13,13 @@ test('scanner and probe paths are not known paths', () => { assert.equal(isKnownSitePath(p), false, p) } }) + +import { checkPassageReference } from '../shared/bible-books.js' + +test('passage reference check accepts what the index can read and flags what it cannot', () => { + assert.equal(checkPassageReference('Titus 1:1-2', ''), null) + assert.equal(checkPassageReference('1:10–13a', 'titus'), null) + assert.match(checkPassageReference('1:1-2', ''), /Bible Book/) + assert.match(checkPassageReference('Foo 1:1', 'titus'), /not a recognized/) + assert.match(checkPassageReference('chapter one', 'titus'), /Use the form/) +})