From 57f7a134743ed43c0b6137acab4c984020e94531 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=7BAI=7Df=20D=2E=20M=C3=BCller?= Date: Tue, 2 Jun 2026 09:43:41 +0200 Subject: [PATCH] feat(analytics): count SPA pageviews incl. each opened anchor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GoatCounter's count.js only fires on the initial full page load, so client-side navigation (opening anchors, switching to docs/contracts) was not counted — the stats only showed entry pages. Report a pageview from the router on every client-side route change, skipping the first handleRoute() so the initial load (already auto-counted by count.js) is not double-counted. Each opened anchor (/anchor/:id) is now recorded with its path and readable title, giving per-anchor view counts. Privacy is unchanged: path only (no query string), no personal data. Co-Authored-By: Claude Opus 4.8 (1M context) --- website/src/utils/router.js | 19 +++++++++++++++++++ website/src/utils/router.test.js | 14 ++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/website/src/utils/router.js b/website/src/utils/router.js index b1f8b68..8c50246 100644 --- a/website/src/utils/router.js +++ b/website/src/utils/router.js @@ -142,6 +142,23 @@ export function initRouter() { /** * Handle route change */ +// SPA pageview tracking for GoatCounter. count.js auto-counts the initial full +// page load, so the first handleRoute() is skipped to avoid a double hit; every +// later client-side route change — including each opened anchor (/anchor/:id) — +// is reported with its resolved path and title, giving per-anchor view counts. +// Privacy is unchanged: path only, no query string, no personal data. +let firstRouteHandled = false +function trackPageview() { + if (!firstRouteHandled) { + firstRouteHandled = true + return + } + const gc = typeof window !== 'undefined' ? window.goatcounter : null + if (gc && typeof gc.count === 'function') { + gc.count({ path: window.location.pathname, title: document.title }) + } +} + function handleRoute() { let path = getCurrentRoute() @@ -176,6 +193,7 @@ function handleRoute() { // Set title to anchor name const readableName = safeAnchorId.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) document.title = `${readableName} — Semantic Anchors` + trackPageview() // Open the anchor modal as overlay on current page import('../components/anchor-modal.js').then(({ showAnchorDetails }) => { @@ -190,6 +208,7 @@ function handleRoute() { currentRoute = path document.title = ROUTE_TITLES[path] || 'Semantic Anchors' handler() + trackPageview() } else { // Default to home if route not found const homeHandler = routes.get('/') diff --git a/website/src/utils/router.test.js b/website/src/utils/router.test.js index becb001..e9aac21 100644 --- a/website/src/utils/router.test.js +++ b/website/src/utils/router.test.js @@ -35,4 +35,18 @@ describe('router', () => { window.dispatchEvent(new PopStateEvent('popstate')) expect(handler).toHaveBeenCalledTimes(2) }) + + it('reports a GoatCounter pageview with the path on SPA navigation', () => { + window.goatcounter = { count: vi.fn() } + addRoute('/gc-test', vi.fn()) + // The very first route of the run is skipped by design (count.js auto-counts + // the initial load); clear and navigate again so we assert a later change. + navigate('/gc-test') + window.goatcounter.count.mockClear() + navigate('/gc-test') + expect(window.goatcounter.count).toHaveBeenCalledWith( + expect.objectContaining({ path: '/gc-test' }) + ) + delete window.goatcounter + }) })