diff --git a/system/public/aesthetic.computer/lib/visit-tracker.mjs b/system/public/aesthetic.computer/lib/visit-tracker.mjs index 323f6f2746..dccc5906f5 100644 --- a/system/public/aesthetic.computer/lib/visit-tracker.mjs +++ b/system/public/aesthetic.computer/lib/visit-tracker.mjs @@ -30,6 +30,14 @@ export function startVisitTracker(win = window, doc = document) { visibleMs = 0; lastSent = ""; lastTick = win.performance.now(); wasVisible = visible(); }; + const syncRoute = () => { + if (path === win.location.pathname) return; + path = win.location.pathname; + // Room/round URLs can change automatically. They are one page visit, + // not new arrivals. Only crossing a private boundary resets measurement. + const allowed = visitSurface(path) !== null; + if (allowed !== Boolean(state)) reset(); + }; const send = () => { if (!state || disabled() || stopped || win.performance.now() < retryAt) return; // Markers can be installed after the initial module by a render harness. @@ -54,7 +62,7 @@ export function startVisitTracker(win = window, doc = document) { }; const tick = () => { if (stopped) return; - if (path !== win.location.pathname) reset(); + syncRoute(); const now = win.performance.now(); // Cap suspension gaps: a sleeping laptop is not engagement time. if (wasVisible) visibleMs += Math.min(2000, Math.max(0, now - lastTick)); @@ -72,7 +80,7 @@ export function startVisitTracker(win = window, doc = document) { }; const action = name => { if (!VISIT_ACTIONS.includes(name) || !visible() || disabled()) return false; - if (path !== win.location.pathname) reset(); + syncRoute(); if (!state?.interacted) return false; if (!state.actions.includes(name)) state.actions.push(name); send(); return true; diff --git a/system/tests/visit-tracking-browser.test.mjs b/system/tests/visit-tracking-browser.test.mjs index fa4e0d3330..9307edefad 100644 --- a/system/tests/visit-tracking-browser.test.mjs +++ b/system/tests/visit-tracking-browser.test.mjs @@ -40,6 +40,9 @@ test("browser funnel, automation, privacy opt-out, private SPA routes and duplic assert.ok(received.some(row => row.interacted && row.activeSeconds >= 10)); const ids = new Set(received.map(row => row.id)); assert.equal(ids.size, 1); + await page.evaluate(() => history.replaceState({}, "", "/some-round")); + await page.clock.runFor(2000); + assert.equal(new Set(received.map(row => row.id)).size, 1, "automatic round URLs are not new visits"); assert.ok(!JSON.stringify(received).includes("secret")); await page.evaluate(async () => (await import("https://aesthetic.computer/aesthetic.computer/lib/visit-tracker.mjs")).startVisitTracker()); await page.evaluate(() => history.pushState({}, "", "/mail")); diff --git a/toolchain/analytics/VISITS.md b/toolchain/analytics/VISITS.md index 7ac180fe0d..af882fffe4 100644 --- a/toolchain/analytics/VISITS.md +++ b/toolchain/analytics/VISITS.md @@ -10,7 +10,7 @@ replays. It does not export operational records to PostHog. | Measure | Definition | | --- | --- | -| Visit | One visible top-level page load, or new SPA pathname, with a random in-memory ID | +| Visit | One visible top-level page load with a random in-memory ID; returning from a private route starts a fresh visit | | Interacted visit | Visible-page trusted pointer/touch/keyboard input outside form fields, or a pressed gamepad button | | Engaged visit | An interacted visit with at least 10 seconds of accumulated visible time | | Action visit | At least one occurrence of a reviewed action during that visit | @@ -22,6 +22,8 @@ An automation framework can generate trusted events. Untagged automation may remain. IPs are not counted as people. No cross-page retention, unique-user, cross-domain journey or conversion-attribution claims can be made from these ephemeral IDs. A returning person loading three pages produces three visits. +In-page SPA navigation and Oskiewar's automatic round-URL changes preserve the +visit ID; the surface dimension describes the broad landing category. Visible-time lower-bound buckets are 0, 10, 30, 60, 180 and 600 seconds. This is foreground display time, not proof of attention. Polling gaps are