Skip to content

Commit d712081

Browse files
authored
1 parent 3a2c93b commit d712081

File tree

3 files changed

+308
-56
lines changed

3 files changed

+308
-56
lines changed

README.md

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,33 @@
1-
# **Plugin Name** Plugin
1+
# discourse-page-visits
22

3-
**Plugin Summary**
3+
## Project History
44

5-
For more information, please see: **url to meta topic**
5+
| Description | Internal Topic | Contributors |
6+
| ------------------------- | ------------------------------------------------ | ------------ |
7+
| Comprehensive page visit tracking and analytics system for Discourse | [project topic](https://dev.discourse.org/t/wix-data-dev-52-hours/131628/) | @discourse |
8+
9+
## Major Custom Features
10+
11+
### 1. Comprehensive Page Visit Tracking System
12+
13+
**Summary (non-technical):**
14+
Tracks detailed analytics about how users interact with pages on the Discourse site. Records time spent on pages, which posts were viewed, and visit patterns for both logged-in and anonymous users. Provides reliable tracking even when users close tabs or navigate away quickly.
15+
16+
**Technical description:**
17+
Implements client-side page visit tracking using multiple event handlers (visibilitychange, pagehide, onPageChange) to ensure visits are logged reliably. Tracks visit duration, viewed post IDs via scroll tracking integration with screen-track service, and captures full URL, IP address, and user agent. Uses sendBeacon API for reliable delivery during page unload events. Includes duplicate prevention logic with timing checks to avoid logging visits immediately on page load. Stores visit data in page_visits table with support for both authenticated and anonymous users.
18+
19+
### 2. Post-Level View Tracking
20+
21+
**Summary (non-technical):**
22+
Tracks which specific posts within a topic were actually viewed by users as they scroll through content. This provides granular analytics about post engagement beyond just topic-level visits.
23+
24+
**Technical description:**
25+
Integrates with Discourse's screen-track service to monitor scroll position and identify which posts are currently visible on screen. Uses debounced scroll event handlers to efficiently capture viewed post IDs. Stores post IDs as an array in the page_visits record, allowing analysis of which posts received the most attention during a visit.
26+
27+
### 3. Reliable Visit Capture Across Navigation Scenarios
28+
29+
**Summary (non-technical):**
30+
Ensures that page visits are accurately recorded regardless of how users leave a page - whether they navigate to another page, close the tab, switch browser tabs, or use browser back/forward buttons. This provides complete analytics coverage without missing data.
31+
32+
**Technical description:**
33+
Implements multiple event listeners to capture visits in all scenarios: onPageChange for normal navigation, visibilitychange for tab switches/minimization, and pagehide for tab closure and browser navigation. Uses sendBeacon API for visibilitychange and pagehide events to ensure data is sent even during page unload. Falls back to standard AJAX for normal navigation with error handling. Includes timing validation to prevent duplicate logging from rapid page changes.

assets/javascripts/discourse/api-initializers/page-visits.js

Lines changed: 147 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -4,50 +4,80 @@ import { withPluginApi } from "discourse/lib/plugin-api";
44

55
let pageVisitData = {};
66
let pageEnter;
7-
let pageExit;
8-
let visitTime;
97
let viewedPostIds = [];
108
let screenTrack;
119
let postStream;
10+
let hasLoggedVisit = false;
11+
let scrollListenerAttached = false;
12+
let session;
1213

1314
export default {
1415
name: "page-visits",
1516
initialize() {
1617
withPluginApi((api) => {
1718
screenTrack = api.container.lookup("service:screen-track");
19+
session = api.container.lookup("service:session");
1820
const currentUser = api.getCurrentUser();
1921
const topicController = api.container.lookup("controller:topic");
2022

21-
// capture when user leaves the page
23+
// capture when user leaves the page (tab switch, minimize, etc.)
2224
document.addEventListener("visibilitychange", () => {
2325
if (document.visibilityState === "hidden") {
24-
if (Object.keys(pageVisitData).length > 0) {
25-
setVisitTime();
26-
createPageVisitRecord(pageVisitData, viewedPostIds, visitTime);
27-
reset();
28-
}
26+
const useBeacon = true;
27+
const skipTimeCheck = true;
28+
flushVisitRecord(useBeacon, skipTimeCheck);
2929
}
3030
});
3131

32-
api.onPageChange(() => {
33-
if (Object.keys(pageVisitData).length > 0) {
34-
setVisitTime();
35-
createPageVisitRecord(pageVisitData, viewedPostIds, visitTime);
36-
reset();
32+
// capture when page is being unloaded (tab close, browser back/forward)
33+
window.addEventListener("pagehide", () => {
34+
const useBeacon = true;
35+
const skipTimeCheck = true;
36+
flushVisitRecord(useBeacon, skipTimeCheck);
37+
});
38+
39+
function setupScrollTracking(topicId, postStreamModel) {
40+
// Remove existing listener if any
41+
if (scrollListenerAttached) {
42+
window.removeEventListener("scroll", scroll);
43+
scrollListenerAttached = false;
3744
}
3845

39-
const topicId = topicController.model?.id || null;
40-
postStream = topicController.model?.postStream;
41-
if (topicId && postStream) {
46+
// Add scroll listener if we're on a topic page
47+
if (topicId && postStreamModel) {
48+
postStream = postStreamModel;
4249
window.addEventListener("scroll", scroll, { passive: true });
50+
scrollListenerAttached = true;
4351
}
52+
}
53+
54+
api.onPageChange(() => {
55+
// Log previous visit before navigating to new page
56+
const useBeacon = false;
57+
flushVisitRecord(useBeacon);
4458

59+
const topicId = topicController.model?.id || null;
60+
const postStreamModel = topicController.model?.postStream;
61+
setupScrollTracking(topicId, postStreamModel);
4562
captureVisitData(currentUser?.id, topicId);
4663
});
4764
});
4865
},
4966
};
5067

68+
function captureVisitData(userId, topicId) {
69+
const data = {
70+
userId: userId || null,
71+
fullUrl: window.location.href,
72+
topicId,
73+
};
74+
75+
pageVisitData = data;
76+
// Initialize pageEnter when user lands on the page
77+
pageEnter = new Date();
78+
hasLoggedVisit = false;
79+
}
80+
5181
function scroll() {
5282
discourseDebounce(this, captureOnScreenPosts, 100);
5383
}
@@ -62,39 +92,114 @@ function captureOnScreenPosts() {
6292
});
6393
}
6494

65-
async function createPageVisitRecord(data, postIds, time) {
66-
await ajax("/page-visits.json", {
67-
type: "POST",
68-
data: {
69-
user_id: data.userId,
70-
full_url: data.fullUrl,
71-
topic_id: data.topicId,
72-
post_ids: postIds,
73-
visit_time: time,
74-
},
75-
});
95+
function flushVisitRecord(useBeacon, skipTimeCheck = false) {
96+
// Don't flush if we've already logged this visit or if there's no data
97+
if (hasLoggedVisit || Object.keys(pageVisitData).length === 0) {
98+
return;
99+
}
100+
101+
// Don't flush if we just started tracking (pageEnter was just set)
102+
// This prevents logging visits immediately when onPageChange fires multiple times
103+
// Skip this check for visibility/pagehide events (skipTimeCheck = true)
104+
if (!skipTimeCheck && pageEnter) {
105+
const timeSincePageEnter = Date.now() - pageEnter.getTime();
106+
// If less than 100ms has passed, we're likely in a duplicate onPageChange call
107+
// Don't log yet - wait for the user to actually navigate away
108+
if (timeSincePageEnter < 100) {
109+
return;
110+
}
111+
}
112+
113+
// Calculate visit time before resetting
114+
if (!pageEnter) {
115+
// Fallback: if pageEnter wasn't set, use current time minus a small buffer
116+
pageEnter = new Date(Date.now() - 1000);
117+
}
118+
const pageExitTime = new Date();
119+
const visitTimeMs = pageExitTime - pageEnter;
120+
121+
// Save current state before resetting (so we can capture new page data immediately)
122+
const savedPageData = { ...pageVisitData };
123+
const savedPostIds = [...viewedPostIds];
124+
125+
// Mark as logged BEFORE resetting to prevent duplicate logs
126+
hasLoggedVisit = true;
127+
128+
// Reset state immediately so we can start tracking the new page
129+
reset();
130+
131+
// Log the saved visit asynchronously
132+
createPageVisitRecord(savedPageData, savedPostIds, visitTimeMs, useBeacon);
76133
}
77134

78-
function captureVisitData(userId, topicId) {
79-
const data = {
80-
userId: userId || null,
81-
fullUrl: window.location.href,
82-
topicId,
135+
async function createPageVisitRecord(data, postIds, time, useBeacon = false) {
136+
const payload = {
137+
user_id: data.userId,
138+
full_url: data.fullUrl,
139+
topic_id: data.topicId,
140+
post_ids: postIds,
141+
visit_time: time,
83142
};
84143

85-
pageVisitData = data;
144+
if (useBeacon && navigator.sendBeacon) {
145+
// Use sendBeacon for reliable delivery during page unload
146+
// FormData is the most reliable format for sendBeacon
147+
const formData = createFormDataFromPayload(payload, session?.csrfToken);
148+
const url = new URL("/page-visits.json", window.location.origin);
149+
navigator.sendBeacon(url.toString(), formData);
150+
} else {
151+
// Use ajax for normal page navigation (more reliable, can handle errors)
152+
try {
153+
await ajax("/page-visits.json", {
154+
type: "POST",
155+
data: payload,
156+
});
157+
} catch {
158+
// If ajax fails and we're unloading, fallback to sendBeacon
159+
if (navigator.sendBeacon) {
160+
const formData = createFormDataFromPayload(payload, session?.csrfToken);
161+
const url = new URL("/page-visits.json", window.location.origin);
162+
navigator.sendBeacon(url.toString(), formData);
163+
}
164+
}
165+
}
166+
}
167+
168+
function createFormDataFromPayload(payload, csrfToken) {
169+
const formData = new FormData();
170+
171+
if (csrfToken) {
172+
formData.append("authenticity_token", csrfToken);
173+
}
174+
175+
if (payload.user_id !== null && payload.user_id !== undefined) {
176+
formData.append("user_id", payload.user_id);
177+
}
178+
if (payload.full_url) {
179+
formData.append("full_url", payload.full_url);
180+
}
181+
if (payload.topic_id !== null && payload.topic_id !== undefined) {
182+
formData.append("topic_id", payload.topic_id);
183+
}
184+
if (Array.isArray(payload.post_ids) && payload.post_ids.length > 0) {
185+
payload.post_ids.forEach((postId) => {
186+
formData.append("post_ids[]", postId);
187+
});
188+
}
189+
if (payload.visit_time !== null && payload.visit_time !== undefined) {
190+
formData.append("visit_time", payload.visit_time);
191+
}
192+
return formData;
86193
}
87194

88195
function reset() {
89-
window.removeEventListener("scroll", scroll);
196+
if (scrollListenerAttached) {
197+
window.removeEventListener("scroll", scroll);
198+
scrollListenerAttached = false;
199+
}
90200
viewedPostIds = [];
91-
pageEnter = new Date();
92-
pageExit = null;
93-
visitTime = null;
201+
pageEnter = null;
94202
pageVisitData = {};
95-
}
96-
97-
function setVisitTime() {
98-
pageExit = new Date();
99-
visitTime = pageExit - pageEnter;
203+
hasLoggedVisit = false;
204+
postStream = null;
100205
}

0 commit comments

Comments
 (0)