tracking and nav
Browse files
api/models/user_activity.py
CHANGED
|
@@ -22,44 +22,101 @@ def _ensure_indexes():
|
|
| 22 |
_ensure_indexes()
|
| 23 |
|
| 24 |
|
| 25 |
-
def ping_user(user_id, username, avatar, page_url, page_title=""):
|
| 26 |
-
"""Record or update a user's presence on the site.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
now = datetime.now(timezone.utc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
activity_collection.update_one(
|
| 29 |
-
|
| 30 |
-
{"$set": {
|
| 31 |
-
"user_id": str(user_id),
|
| 32 |
-
"username": username,
|
| 33 |
-
"avatar": avatar or "",
|
| 34 |
-
"page_url": page_url,
|
| 35 |
-
"page_title": page_title,
|
| 36 |
-
"last_seen": now,
|
| 37 |
-
},
|
| 38 |
-
"$setOnInsert": {
|
| 39 |
-
"first_seen": now,
|
| 40 |
-
}},
|
| 41 |
upsert=True
|
| 42 |
)
|
| 43 |
return True
|
| 44 |
|
| 45 |
|
| 46 |
def get_active_users(minutes=5):
|
| 47 |
-
"""Get users active within the last N minutes.
|
|
|
|
|
|
|
| 48 |
cutoff = datetime.now(timezone.utc) - timedelta(minutes=minutes)
|
| 49 |
cursor = activity_collection.find(
|
| 50 |
{"last_seen": {"$gte": cutoff}}
|
| 51 |
).sort("last_seen", DESCENDING)
|
| 52 |
-
|
|
|
|
| 53 |
users = []
|
| 54 |
for doc in cursor:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
users.append({
|
| 56 |
"user_id": doc.get("user_id", ""),
|
| 57 |
-
"username":
|
| 58 |
"avatar": doc.get("avatar", ""),
|
| 59 |
"page_url": doc.get("page_url", ""),
|
| 60 |
"page_title": doc.get("page_title", ""),
|
| 61 |
"last_seen": doc["last_seen"].isoformat() if doc.get("last_seen") else None,
|
| 62 |
"first_seen": doc["first_seen"].isoformat() if doc.get("first_seen") else None,
|
|
|
|
| 63 |
})
|
| 64 |
return users
|
| 65 |
|
|
@@ -70,4 +127,3 @@ def get_active_user_count(minutes=5):
|
|
| 70 |
return activity_collection.count_documents({"last_seen": {"$gte": cutoff}})
|
| 71 |
|
| 72 |
|
| 73 |
-
|
|
|
|
| 22 |
_ensure_indexes()
|
| 23 |
|
| 24 |
|
| 25 |
+
def ping_user(user_id, username, avatar, page_url, page_title="", anonymous_id=None, ip_address=None):
|
| 26 |
+
"""Record or update a user's presence on the site.
|
| 27 |
+
|
| 28 |
+
For authenticated users:
|
| 29 |
+
- keyed by user_id (MongoDB ObjectId str)
|
| 30 |
+
- shows their username & avatar
|
| 31 |
+
|
| 32 |
+
For anonymous visitors:
|
| 33 |
+
- keyed by anonymous_id (client-generated UUID stored in localStorage)
|
| 34 |
+
- deduplicated by ip_address if anonymous_id not provided
|
| 35 |
+
- shows "Guest" in the activity feed
|
| 36 |
+
"""
|
| 37 |
now = datetime.now(timezone.utc)
|
| 38 |
+
|
| 39 |
+
if user_id and username:
|
| 40 |
+
# Authenticated user
|
| 41 |
+
filter_key = {"user_id": str(user_id)}
|
| 42 |
+
display_name = username
|
| 43 |
+
elif anonymous_id:
|
| 44 |
+
# Anonymous user with client ID
|
| 45 |
+
filter_key = {"anonymous_id": anonymous_id}
|
| 46 |
+
display_name = "Guest"
|
| 47 |
+
elif ip_address:
|
| 48 |
+
# Anonymous user identified by IP (fallback)
|
| 49 |
+
filter_key = {"ip_address": ip_address, "user_id": {"$exists": False}}
|
| 50 |
+
display_name = "Guest"
|
| 51 |
+
else:
|
| 52 |
+
return False
|
| 53 |
+
|
| 54 |
+
doc = {
|
| 55 |
+
"last_seen": now,
|
| 56 |
+
"page_url": page_url,
|
| 57 |
+
"page_title": page_title,
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
if user_id and username:
|
| 61 |
+
doc["user_id"] = str(user_id)
|
| 62 |
+
doc["username"] = username
|
| 63 |
+
doc["avatar"] = avatar or ""
|
| 64 |
+
# Remove any anonymous fields if user later logs in
|
| 65 |
+
activity_collection.update_many(
|
| 66 |
+
{"ip_address": ip_address, "user_id": {"$exists": False}},
|
| 67 |
+
{"$set": {"user_id": str(user_id), "username": username, "avatar": avatar or ""}}
|
| 68 |
+
)
|
| 69 |
+
else:
|
| 70 |
+
doc["is_anonymous"] = True
|
| 71 |
+
doc["username"] = "Guest"
|
| 72 |
+
doc["avatar"] = ""
|
| 73 |
+
if anonymous_id:
|
| 74 |
+
doc["anonymous_id"] = anonymous_id
|
| 75 |
+
if ip_address:
|
| 76 |
+
doc["ip_address"] = ip_address
|
| 77 |
+
|
| 78 |
activity_collection.update_one(
|
| 79 |
+
filter_key,
|
| 80 |
+
{"$set": doc, "$setOnInsert": {"first_seen": now}},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
upsert=True
|
| 82 |
)
|
| 83 |
return True
|
| 84 |
|
| 85 |
|
| 86 |
def get_active_users(minutes=5):
|
| 87 |
+
"""Get users active within the last N minutes.
|
| 88 |
+
Returns: list of dicts with user info including is_anonymous flag.
|
| 89 |
+
"""
|
| 90 |
cutoff = datetime.now(timezone.utc) - timedelta(minutes=minutes)
|
| 91 |
cursor = activity_collection.find(
|
| 92 |
{"last_seen": {"$gte": cutoff}}
|
| 93 |
).sort("last_seen", DESCENDING)
|
| 94 |
+
|
| 95 |
+
seen_ids = set()
|
| 96 |
users = []
|
| 97 |
for doc in cursor:
|
| 98 |
+
# Deduplicate: prefer authenticated version over anonymous
|
| 99 |
+
uid = doc.get("user_id") or doc.get("anonymous_id") or doc.get("ip_address") or ""
|
| 100 |
+
if uid in seen_ids:
|
| 101 |
+
continue
|
| 102 |
+
seen_ids.add(uid)
|
| 103 |
+
|
| 104 |
+
is_anon = doc.get("is_anonymous", False) or not doc.get("user_id")
|
| 105 |
+
display_name = doc.get("username", "Guest")
|
| 106 |
+
# Differentiate anonymous guests with a short ID suffix
|
| 107 |
+
if is_anon and doc.get("anonymous_id"):
|
| 108 |
+
short_id = str(doc["anonymous_id"])[-5:]
|
| 109 |
+
display_name = f"Guest_{short_id}"
|
| 110 |
+
|
| 111 |
users.append({
|
| 112 |
"user_id": doc.get("user_id", ""),
|
| 113 |
+
"username": display_name,
|
| 114 |
"avatar": doc.get("avatar", ""),
|
| 115 |
"page_url": doc.get("page_url", ""),
|
| 116 |
"page_title": doc.get("page_title", ""),
|
| 117 |
"last_seen": doc["last_seen"].isoformat() if doc.get("last_seen") else None,
|
| 118 |
"first_seen": doc["first_seen"].isoformat() if doc.get("first_seen") else None,
|
| 119 |
+
"is_anonymous": is_anon,
|
| 120 |
})
|
| 121 |
return users
|
| 122 |
|
|
|
|
| 127 |
return activity_collection.count_documents({"last_seen": {"$gte": cutoff}})
|
| 128 |
|
| 129 |
|
|
|
api/routes/shared/bug_reports_api.py
CHANGED
|
@@ -296,21 +296,40 @@ def remove_announcement(announcement_id):
|
|
| 296 |
|
| 297 |
@bug_reports_api_bp.route("/activity/ping", methods=["POST"])
|
| 298 |
def user_heartbeat():
|
| 299 |
-
"""POST /api/activity/ping — record user activity
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
data = request.get_json() or {}
|
| 304 |
page_url = data.get("page_url", request.referrer or "/")
|
| 305 |
page_title = data.get("page_title", "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
|
| 307 |
-
ping_user(
|
| 308 |
-
user_id=session["_id"],
|
| 309 |
-
username=session["username"],
|
| 310 |
-
avatar=session.get("avatar", ""),
|
| 311 |
-
page_url=page_url,
|
| 312 |
-
page_title=page_title,
|
| 313 |
-
)
|
| 314 |
return jsonify({"success": True})
|
| 315 |
|
| 316 |
|
|
|
|
| 296 |
|
| 297 |
@bug_reports_api_bp.route("/activity/ping", methods=["POST"])
|
| 298 |
def user_heartbeat():
|
| 299 |
+
"""POST /api/activity/ping — record user activity.
|
| 300 |
+
Works for both logged-in and anonymous visitors.
|
| 301 |
+
"""
|
|
|
|
| 302 |
data = request.get_json() or {}
|
| 303 |
page_url = data.get("page_url", request.referrer or "/")
|
| 304 |
page_title = data.get("page_title", "")
|
| 305 |
+
anonymous_id = data.get("anonymous_id")
|
| 306 |
+
ip_address = request.remote_addr or ""
|
| 307 |
+
|
| 308 |
+
if "username" in session and "_id" in session:
|
| 309 |
+
# Logged-in user
|
| 310 |
+
ping_user(
|
| 311 |
+
user_id=str(session["_id"]),
|
| 312 |
+
username=session["username"],
|
| 313 |
+
avatar=session.get("avatar", ""),
|
| 314 |
+
page_url=page_url,
|
| 315 |
+
page_title=page_title,
|
| 316 |
+
ip_address=ip_address,
|
| 317 |
+
)
|
| 318 |
+
elif anonymous_id:
|
| 319 |
+
# Anonymous visitor with client ID
|
| 320 |
+
ping_user(
|
| 321 |
+
user_id=None,
|
| 322 |
+
username=None,
|
| 323 |
+
avatar=None,
|
| 324 |
+
page_url=page_url,
|
| 325 |
+
page_title=page_title,
|
| 326 |
+
anonymous_id=anonymous_id,
|
| 327 |
+
ip_address=ip_address,
|
| 328 |
+
)
|
| 329 |
+
else:
|
| 330 |
+
# Anonymous without ID — skip silently
|
| 331 |
+
return jsonify({"success": False, "message": "No identifier"}), 200
|
| 332 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
return jsonify({"success": True})
|
| 334 |
|
| 335 |
|
api/templates/shared/admin_announcements.html
CHANGED
|
@@ -344,8 +344,11 @@
|
|
| 344 |
</style>
|
| 345 |
{% endblock %}
|
| 346 |
|
| 347 |
-
{% block content %}
|
| 348 |
-
<
|
|
|
|
|
|
|
|
|
|
| 349 |
<div class="aa-header">
|
| 350 |
<h1>
|
| 351 |
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: var(--aa-warning);">
|
|
|
|
| 344 |
</style>
|
| 345 |
{% endblock %}
|
| 346 |
|
| 347 |
+
{% block content %} <div class="aa-page">
|
| 348 |
+
<a href="/admin" style="display:inline-flex;align-items:center;gap:5px;font-size:0.78rem;color:#555;text-decoration:none;margin-bottom:14px;transition:color 0.2s;" onmouseover="this.style.color='#fff'" onmouseout="this.style.color='#555'">
|
| 349 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
|
| 350 |
+
Back to Dashboard
|
| 351 |
+
</a>
|
| 352 |
<div class="aa-header">
|
| 353 |
<h1>
|
| 354 |
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: var(--aa-warning);">
|
api/templates/shared/admin_bug_reports.html
CHANGED
|
@@ -482,8 +482,11 @@
|
|
| 482 |
</style>
|
| 483 |
{% endblock %}
|
| 484 |
|
| 485 |
-
{% block content %}
|
| 486 |
-
<
|
|
|
|
|
|
|
|
|
|
| 487 |
<div class="abg-header">
|
| 488 |
<h1>
|
| 489 |
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: var(--abg-error);">
|
|
|
|
| 482 |
</style>
|
| 483 |
{% endblock %}
|
| 484 |
|
| 485 |
+
{% block content %} <div class="abg-page">
|
| 486 |
+
<a href="/admin" style="display:inline-flex;align-items:center;gap:5px;font-size:0.78rem;color:#555;text-decoration:none;margin-bottom:14px;transition:color 0.2s;" onmouseover="this.style.color='#fff'" onmouseout="this.style.color='#555'">
|
| 487 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
|
| 488 |
+
Back to Dashboard
|
| 489 |
+
</a>
|
| 490 |
<div class="abg-header">
|
| 491 |
<h1>
|
| 492 |
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: var(--abg-error);">
|
api/templates/shared/admin_dashboard.html
CHANGED
|
@@ -635,18 +635,32 @@
|
|
| 635 |
activityList.innerHTML = '<div class="ad-empty">No users currently active.</div>';
|
| 636 |
} else {
|
| 637 |
activityList.innerHTML = activeUsers.map(u => {
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 644 |
|
| 645 |
return `
|
| 646 |
<div class="ad-activity-item">
|
| 647 |
-
${
|
| 648 |
<div class="ad-activity-info">
|
| 649 |
-
<div class="ad-activity-username">${
|
| 650 |
<div class="ad-activity-page" title="${u.page_url || ''}">📍 ${pageLabel}</div>
|
| 651 |
</div>
|
| 652 |
<div class="ad-activity-time">${timeLabel}</div>
|
|
|
|
| 635 |
activityList.innerHTML = '<div class="ad-empty">No users currently active.</div>';
|
| 636 |
} else {
|
| 637 |
activityList.innerHTML = activeUsers.map(u => {
|
| 638 |
+
var isGuest = u.is_anonymous === true;
|
| 639 |
+
var initial = (u.username || '?')[0].toUpperCase();
|
| 640 |
+
var avatarHtml;
|
| 641 |
+
if (isGuest) {
|
| 642 |
+
avatarHtml = `<div class="ad-activity-avatar-placeholder" style="background:#1a1a2e;color:#888;">
|
| 643 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 644 |
+
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
| 645 |
+
<circle cx="9" cy="7" r="4"></circle>
|
| 646 |
+
</svg>
|
| 647 |
+
</div>`;
|
| 648 |
+
} else if (u.avatar) {
|
| 649 |
+
avatarHtml = `<img class="ad-activity-avatar" src="${u.avatar}" alt="${u.username}" onerror="this.style.display='none'">`;
|
| 650 |
+
} else {
|
| 651 |
+
avatarHtml = `<div class="ad-activity-avatar-placeholder">${initial}</div>`;
|
| 652 |
+
}
|
| 653 |
+
var usernameHtml = isGuest
|
| 654 |
+
? `<span style="color:#666;">${u.username}</span>`
|
| 655 |
+
: `<span>${u.username}</span>`;
|
| 656 |
+
var pageLabel = u.page_title || getPageLabel(u.page_url);
|
| 657 |
+
var timeLabel = timeAgo(u.last_seen);
|
| 658 |
|
| 659 |
return `
|
| 660 |
<div class="ad-activity-item">
|
| 661 |
+
${avatarHtml}
|
| 662 |
<div class="ad-activity-info">
|
| 663 |
+
<div class="ad-activity-username">${usernameHtml}</div>
|
| 664 |
<div class="ad-activity-page" title="${u.page_url || ''}">📍 ${pageLabel}</div>
|
| 665 |
</div>
|
| 666 |
<div class="ad-activity-time">${timeLabel}</div>
|
api/templates/shared/base.html
CHANGED
|
@@ -1061,21 +1061,38 @@
|
|
| 1061 |
<script src="{{ url_for('static', filename='js/search.js') }}"></script>
|
| 1062 |
<script src="{{ url_for('static', filename='js/notifications.js') }}"></script>
|
| 1063 |
|
| 1064 |
-
<!-- User Activity Heartbeat -->
|
| 1065 |
-
{% if session.get('username') %}
|
| 1066 |
<script>
|
| 1067 |
(function() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1068 |
var heartbeatInterval = 30000; // 30 seconds
|
| 1069 |
|
| 1070 |
function sendHeartbeat() {
|
| 1071 |
var pageUrl = window.location.pathname + window.location.search;
|
| 1072 |
var pageTitle = document.title;
|
| 1073 |
|
| 1074 |
-
var payload = JSON.stringify({
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1075 |
|
| 1076 |
-
// Use sendBeacon for
|
| 1077 |
if (navigator.sendBeacon) {
|
| 1078 |
try {
|
|
|
|
| 1079 |
var blob = new Blob([payload], { type: 'application/json' });
|
| 1080 |
navigator.sendBeacon('/api/activity/ping', blob);
|
| 1081 |
} catch (e) {
|
|
@@ -1083,6 +1100,7 @@
|
|
| 1083 |
method: 'POST',
|
| 1084 |
headers: { 'Content-Type': 'application/json' },
|
| 1085 |
body: payload,
|
|
|
|
| 1086 |
keepalive: true
|
| 1087 |
}).catch(function(){});
|
| 1088 |
}
|
|
@@ -1090,18 +1108,20 @@
|
|
| 1090 |
fetch('/api/activity/ping', {
|
| 1091 |
method: 'POST',
|
| 1092 |
headers: { 'Content-Type': 'application/json' },
|
| 1093 |
-
body: payload
|
|
|
|
|
|
|
| 1094 |
}).catch(function(){});
|
| 1095 |
}
|
| 1096 |
}
|
| 1097 |
|
| 1098 |
-
// Send immediately on page load
|
| 1099 |
-
|
| 1100 |
|
| 1101 |
// Send periodically
|
| 1102 |
setInterval(sendHeartbeat, heartbeatInterval);
|
| 1103 |
|
| 1104 |
-
// Send heartbeat when user leaves the page
|
| 1105 |
window.addEventListener('visibilitychange', function() {
|
| 1106 |
if (document.visibilityState === 'hidden') {
|
| 1107 |
sendHeartbeat();
|
|
@@ -1109,7 +1129,6 @@
|
|
| 1109 |
});
|
| 1110 |
})();
|
| 1111 |
</script>
|
| 1112 |
-
{% endif %}
|
| 1113 |
|
| 1114 |
<!-- Announcement Popup Script -->
|
| 1115 |
<script>
|
|
|
|
| 1061 |
<script src="{{ url_for('static', filename='js/search.js') }}"></script>
|
| 1062 |
<script src="{{ url_for('static', filename='js/notifications.js') }}"></script>
|
| 1063 |
|
| 1064 |
+
<!-- User Activity Heartbeat (tracks ALL visitors — logged-in + anonymous) -->
|
|
|
|
| 1065 |
<script>
|
| 1066 |
(function() {
|
| 1067 |
+
// Get or create anonymous visitor ID
|
| 1068 |
+
var ANON_KEY = '_ac_anon_id';
|
| 1069 |
+
var anonymousId = null;
|
| 1070 |
+
try {
|
| 1071 |
+
anonymousId = localStorage.getItem(ANON_KEY);
|
| 1072 |
+
if (!anonymousId) {
|
| 1073 |
+
anonymousId = 'anon_' + Date.now() + '_' + Math.random().toString(36).substring(2, 10);
|
| 1074 |
+
localStorage.setItem(ANON_KEY, anonymousId);
|
| 1075 |
+
}
|
| 1076 |
+
} catch (e) {
|
| 1077 |
+
anonymousId = 'anon_' + Date.now();
|
| 1078 |
+
}
|
| 1079 |
+
|
| 1080 |
var heartbeatInterval = 30000; // 30 seconds
|
| 1081 |
|
| 1082 |
function sendHeartbeat() {
|
| 1083 |
var pageUrl = window.location.pathname + window.location.search;
|
| 1084 |
var pageTitle = document.title;
|
| 1085 |
|
| 1086 |
+
var payload = JSON.stringify({
|
| 1087 |
+
page_url: pageUrl,
|
| 1088 |
+
page_title: pageTitle,
|
| 1089 |
+
anonymous_id: anonymousId
|
| 1090 |
+
});
|
| 1091 |
|
| 1092 |
+
// Use sendBeacon for unload reliability, fallback to fetch
|
| 1093 |
if (navigator.sendBeacon) {
|
| 1094 |
try {
|
| 1095 |
+
// Must use a Blob with explicit content-type so Flask parses it as JSON
|
| 1096 |
var blob = new Blob([payload], { type: 'application/json' });
|
| 1097 |
navigator.sendBeacon('/api/activity/ping', blob);
|
| 1098 |
} catch (e) {
|
|
|
|
| 1100 |
method: 'POST',
|
| 1101 |
headers: { 'Content-Type': 'application/json' },
|
| 1102 |
body: payload,
|
| 1103 |
+
credentials: 'same-origin',
|
| 1104 |
keepalive: true
|
| 1105 |
}).catch(function(){});
|
| 1106 |
}
|
|
|
|
| 1108 |
fetch('/api/activity/ping', {
|
| 1109 |
method: 'POST',
|
| 1110 |
headers: { 'Content-Type': 'application/json' },
|
| 1111 |
+
body: payload,
|
| 1112 |
+
credentials: 'same-origin',
|
| 1113 |
+
keepalive: true
|
| 1114 |
}).catch(function(){});
|
| 1115 |
}
|
| 1116 |
}
|
| 1117 |
|
| 1118 |
+
// Send immediately on page load (with slight delay to not block rendering)
|
| 1119 |
+
setTimeout(sendHeartbeat, 500);
|
| 1120 |
|
| 1121 |
// Send periodically
|
| 1122 |
setInterval(sendHeartbeat, heartbeatInterval);
|
| 1123 |
|
| 1124 |
+
// Send heartbeat when user leaves the page
|
| 1125 |
window.addEventListener('visibilitychange', function() {
|
| 1126 |
if (document.visibilityState === 'hidden') {
|
| 1127 |
sendHeartbeat();
|
|
|
|
| 1129 |
});
|
| 1130 |
})();
|
| 1131 |
</script>
|
|
|
|
| 1132 |
|
| 1133 |
<!-- Announcement Popup Script -->
|
| 1134 |
<script>
|