diff --git a/CHANGELOG.md b/CHANGELOG.md index dabfab1..aff8479 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Improved +- **Visit duration now works for single-page sessions.** Previously, if a visitor viewed only one page and left, the visit duration showed as "0s" because there was no second pageview to measure against. Pulse now tracks how long you actually spent on the page and reports real durations — even for single-page visits. This makes the Visit Duration metric, Journeys, and Top Paths much more accurate. +- **Journeys page now shows data on low-traffic sites.** The Journeys page previously required at least 2–3 sessions following the same path before showing any data. It now shows all navigation flows immediately, so you can see how visitors move through your site from day one. - **European date and time formatting.** All dates across Pulse now use day-first ordering (14 Mar 2025) and 24-hour time (14:30) instead of the US-style month-first format. This applies everywhere — dashboard charts, exports, billing dates, invoices, uptime checks, audit logs, and more. ### Added diff --git a/public/script.js b/public/script.js index 00ed811..02b2750 100644 --- a/public/script.js +++ b/public/script.js @@ -39,6 +39,9 @@ let clsObserved = false; let performanceInsightsEnabled = false; + // * Time-on-page tracking: records when the current pageview started + var pageStartTime = 0; + // * Minimal Web Vitals Observer function observeMetrics() { try { @@ -79,16 +82,30 @@ } function sendMetrics() { - if (!performanceInsightsEnabled || !currentEventId) return; + if (!currentEventId) return; - // * Only include LCP/CLS when the browser actually reported them. Sending 0 overwrites - // * the DB before LCP/CLS have fired (they fire late). The backend does partial updates - // * and leaves unset fields unchanged. - const payload = { event_id: currentEventId, inp: metrics.inp }; - if (lcpObserved) payload.lcp = metrics.lcp; - if (clsObserved) payload.cls = metrics.cls; + // * Calculate time-on-page in seconds (always sent, even without performance insights) + var durationSec = pageStartTime > 0 ? Math.round((Date.now() - pageStartTime) / 1000) : 0; - const data = JSON.stringify(payload); + var payload = { event_id: currentEventId }; + + // * Always include duration if we have a valid measurement + if (durationSec > 0) payload.duration = durationSec; + + // * Only include Web Vitals when performance insights are enabled + if (performanceInsightsEnabled) { + payload.inp = metrics.inp; + // * Only include LCP/CLS when the browser actually reported them. Sending 0 overwrites + // * the DB before LCP/CLS have fired (they fire late). The backend does partial updates + // * and leaves unset fields unchanged. + if (lcpObserved) payload.lcp = metrics.lcp; + if (clsObserved) payload.cls = metrics.cls; + } + + // * Skip if nothing to send (no duration and no vitals) + if (!payload.duration && !performanceInsightsEnabled) return; + + var data = JSON.stringify(payload); if (navigator.sendBeacon) { navigator.sendBeacon(apiUrl + '/api/v1/metrics', new Blob([data], {type: 'application/json'})); @@ -301,7 +318,7 @@ } if (currentEventId) { - // * SPA nav: visibilitychange may not fire, so send previous page's metrics now + // * SPA nav: visibilitychange may not fire, so send previous page's metrics + duration now sendMetrics(); } @@ -309,6 +326,7 @@ lcpObserved = false; clsObserved = false; currentEventId = null; + pageStartTime = 0; // * Only send external referrer on the first pageview (landing page). // * SPA navigations keep document.referrer stale, so clear it after first hit // * to avoid inflating traffic source attribution. @@ -357,6 +375,7 @@ firstPageviewSent = true; if (data && data.id) { currentEventId = data.id; + pageStartTime = Date.now(); // * For SPA navigations the browser never emits a new largest-contentful-paint // * (LCP is only for full document loads). After the new view has had time to // * paint, we record time-from-route-change as an LCP proxy so /products etc.