Skip to content

Commit e2be797

Browse files
committed
1 parent 3a2c93b commit e2be797

File tree

4 files changed

+313
-52
lines changed

4 files changed

+313
-52
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.

app/controllers/discourse_page_visits/page_visits_controller.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ module ::DiscoursePageVisits
44
class PageVisitsController < ::ApplicationController
55
requires_plugin PLUGIN_NAME
66

7+
skip_before_action :verify_authenticity_token, only: [:create]
8+
79
def create
810
params_with_request =
911
page_visit_params.merge(ip_address: request.remote_ip, user_agent: request.user_agent)

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

Lines changed: 150 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ let visitTime;
99
let viewedPostIds = [];
1010
let screenTrack;
1111
let postStream;
12+
let hasLoggedVisit = false;
13+
let scrollListenerAttached = false;
1214

1315
export default {
1416
name: "page-visits",
@@ -18,36 +20,64 @@ export default {
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+
}
4453

54+
api.onPageChange(() => {
55+
// Log previous visit before navigating to new page
56+
const useBeacon = false;
57+
flushVisitRecord(useBeacon);
58+
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,121 @@ 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 setVisitTime() {
96+
if (!pageEnter) {
97+
// Fallback: if pageEnter wasn't set, use current time minus a small buffer
98+
pageEnter = new Date(Date.now() - 1000);
99+
}
100+
pageExit = new Date();
101+
visitTime = pageExit - pageEnter;
76102
}
77103

78-
function captureVisitData(userId, topicId) {
79-
const data = {
80-
userId: userId || null,
81-
fullUrl: window.location.href,
82-
topicId,
104+
function flushVisitRecord(useBeacon, skipTimeCheck = false) {
105+
// Don't flush if we've already logged this visit or if there's no data
106+
if (hasLoggedVisit || Object.keys(pageVisitData).length === 0) {
107+
return;
108+
}
109+
110+
// Don't flush if we just started tracking (pageEnter was just set)
111+
// This prevents logging visits immediately when onPageChange fires multiple times
112+
// Skip this check for visibility/pagehide events (skipTimeCheck = true)
113+
if (!skipTimeCheck && pageEnter) {
114+
const timeSincePageEnter = Date.now() - pageEnter.getTime();
115+
// If less than 100ms has passed, we're likely in a duplicate onPageChange call
116+
// Don't log yet - wait for the user to actually navigate away
117+
if (timeSincePageEnter < 100) {
118+
return;
119+
}
120+
}
121+
122+
// Calculate visit time before resetting
123+
if (!pageEnter) {
124+
// Fallback: if pageEnter wasn't set, use current time minus a small buffer
125+
pageEnter = new Date(Date.now() - 1000);
126+
}
127+
const pageExitTime = new Date();
128+
const visitTimeMs = pageExitTime - pageEnter;
129+
130+
// Save current state before resetting (so we can capture new page data immediately)
131+
const savedPageData = { ...pageVisitData };
132+
const savedPostIds = [...viewedPostIds];
133+
134+
// Mark as logged BEFORE resetting to prevent duplicate logs
135+
hasLoggedVisit = true;
136+
137+
// Reset state immediately so we can start tracking the new page
138+
reset();
139+
140+
// Log the saved visit asynchronously
141+
createPageVisitRecord(savedPageData, savedPostIds, visitTimeMs, useBeacon);
142+
}
143+
144+
async function createPageVisitRecord(data, postIds, time, useBeacon = false) {
145+
const payload = {
146+
user_id: data.userId,
147+
full_url: data.fullUrl,
148+
topic_id: data.topicId,
149+
post_ids: postIds,
150+
visit_time: time,
83151
};
84152

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

88200
function reset() {
89-
window.removeEventListener("scroll", scroll);
201+
if (scrollListenerAttached) {
202+
window.removeEventListener("scroll", scroll);
203+
scrollListenerAttached = false;
204+
}
90205
viewedPostIds = [];
91-
pageEnter = new Date();
206+
pageEnter = null;
92207
pageExit = null;
93208
visitTime = null;
94209
pageVisitData = {};
95-
}
96-
97-
function setVisitTime() {
98-
pageExit = new Date();
99-
visitTime = pageExit - pageEnter;
210+
hasLoggedVisit = false;
211+
postStream = null;
100212
}

0 commit comments

Comments
 (0)