Skip to content

Commit 208a599

Browse files
committed
Refactor analytics handling for improved clarity and efficiency;
1 parent 4742694 commit 208a599

3 files changed

Lines changed: 127 additions & 160 deletions

File tree

profile/analytics.gs

Lines changed: 56 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,91 +1,90 @@
11
/**
22
* Google Apps Script backend for tccards.tn analytics
3+
* Receives analytics via GET requests (?data=JSON) — same pattern as profile.gs
34
*/
45

5-
// Configuration
66
const SPREADSHEET_ID = '1kSpvcLl1onOsi46sp-9Ut1zp9iVIkJHOJAAT5ZzaGkI';
77
const SHEET_NAME = 'Analysis';
88

9-
// Main function to handle POST requests
10-
function doPost(e) {
9+
function doGet(e) {
1110
try {
12-
// Parse incoming data — works whether Content-Type is application/json or text/plain
13-
const data = JSON.parse(e.postData.contents);
11+
// --- Test endpoint ---
12+
if (e.parameter.action === 'test') {
13+
return jsonResponse({ status: 'active', message: 'Analytics endpoint is working' });
14+
}
15+
16+
// --- Analytics payload arrives as ?data=<JSON> ---
17+
if (!e.parameter.data) {
18+
return jsonResponse({ error: 'Missing data parameter' });
19+
}
1420

15-
// Validate required fields
16-
if (!data.fullState?.link || !data.action) {
17-
return jsonResponse({error: 'Missing required fields: link and action'});
21+
const data = JSON.parse(e.parameter.data);
22+
23+
if (!data.link || !data.action) {
24+
return jsonResponse({ error: 'Missing required fields: link and action' });
1825
}
1926

2027
const sheet = getSheet();
21-
const profileData = getOrCreateProfile(sheet, data.fullState.link);
28+
const profile = getOrCreateProfile(sheet, data.link);
2229
const nowIso = new Date().toISOString();
2330

24-
switch(data.action) {
31+
switch (data.action) {
2532
case 'visit':
26-
profileData.totalVisits = (profileData.totalVisits || 0) + 1;
27-
profileData.lastVisitAt = nowIso;
33+
profile.totalVisits = (Number(profile.totalVisits) || 0) + 1;
34+
profile.lastVisitAt = nowIso;
2835
break;
2936
case 'share':
30-
profileData.shareCount = (profileData.shareCount || 0) + 1;
31-
profileData.lastShareAt = nowIso;
37+
profile.shareCount = (Number(profile.shareCount) || 0) + 1;
38+
profile.lastShareAt = nowIso;
3239
break;
3340
case 'contact':
34-
profileData.contactCount = (profileData.contactCount || 0) + 1;
35-
profileData.lastContactAt = nowIso;
41+
profile.contactCount = (Number(profile.contactCount) || 0) + 1;
42+
profile.lastContactAt = nowIso;
3643
break;
3744
case 'copy':
38-
profileData.copyCount = (profileData.copyCount || 0) + 1;
39-
profileData.lastCopyAt = nowIso;
45+
profile.copyCount = (Number(profile.copyCount) || 0) + 1;
46+
profile.lastCopyAt = nowIso;
4047
break;
4148
case 'social':
4249
if (data.detail) {
43-
const socialKey = 'social_' + data.detail.id;
44-
profileData[socialKey] = (profileData[socialKey] || 0) + 1;
45-
profileData['lastSocialAt_' + data.detail.id] = nowIso;
46-
profileData['lastSocialHref_' + data.detail.id] = data.detail.href || '';
50+
const key = 'social_' + data.detail.id;
51+
profile[key] = (Number(profile[key]) || 0) + 1;
52+
profile['lastSocialAt_' + data.detail.id] = nowIso;
53+
profile['lastSocialHref_' + data.detail.id] = data.detail.href || '';
4754
}
4855
break;
49-
case 'init':
50-
profileData.totalVisits = data.fullState.totalVisits || 0;
51-
profileData.shareCount = data.fullState.shareCount || 0;
52-
profileData.contactCount = data.fullState.contactCount || 0;
53-
profileData.copyCount = data.fullState.copyCount || 0;
54-
break;
5556
}
5657

57-
// Sync social counts from fullState
58-
if (data.fullState.socialCounts) {
59-
Object.entries(data.fullState.socialCounts).forEach(([key, count]) => {
60-
profileData['social_' + key] = count;
58+
// Sync social counts from client state
59+
if (data.socialCounts && typeof data.socialCounts === 'object') {
60+
Object.entries(data.socialCounts).forEach(([key, count]) => {
61+
profile['social_' + key] = count;
6162
});
6263
}
6364

64-
profileData.lastUpdated = nowIso;
65-
saveProfileData(sheet, profileData);
65+
profile.lastUpdated = nowIso;
66+
saveProfileData(sheet, profile);
6667

67-
return jsonResponse({success: true, action: data.action});
68+
return jsonResponse({ success: true, action: data.action, link: data.link });
6869

69-
} catch (error) {
70-
console.error('Error processing analytics data:', error);
71-
return jsonResponse({error: error.message});
70+
} catch (err) {
71+
console.error('Analytics doGet error:', err);
72+
return jsonResponse({ error: err.message });
7273
}
7374
}
7475

75-
// Returns a plain JSON TextOutput — ContentService does NOT support setHeader()
76-
// or setResponseCode(), so we omit them entirely. Apps Script handles CORS for
77-
// no-cors POST requests automatically (the browser doesn't read the response anyway).
76+
// Apps Script ContentService — no setHeader/setResponseCode, those don't exist
7877
function jsonResponse(data) {
7978
const output = ContentService.createTextOutput(JSON.stringify(data));
8079
output.setMimeType(ContentService.MimeType.JSON);
8180
return output;
8281
}
8382

8483
function getSheet() {
85-
const spreadsheet = SpreadsheetApp.openById(SPREADSHEET_ID);
86-
let sheet = spreadsheet.getSheetByName(SHEET_NAME);
84+
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
85+
let sheet = ss.getSheetByName(SHEET_NAME);
8786
if (!sheet) {
88-
sheet = spreadsheet.insertSheet(SHEET_NAME);
87+
sheet = ss.insertSheet(SHEET_NAME);
8988
const headers = ['link', 'totalVisits', 'shareCount', 'contactCount', 'copyCount', 'lastUpdated'];
9089
sheet.getRange(1, 1, 1, headers.length).setValues([headers]);
9190
}
@@ -99,83 +98,62 @@ function getOrCreateProfile(sheet, link) {
9998
const linkCol = headers.indexOf('link') + 1;
10099
if (linkCol === 0) throw new Error('No link column found');
101100

102-
// Guard: only read data rows if they exist (lastRow > 1)
103101
if (lastRow > 1) {
104102
const linkCells = sheet.getRange(2, linkCol, lastRow - 1, 1).getValues();
105103
for (let i = 0; i < linkCells.length; i++) {
106104
if (linkCells[i][0] === link) {
107105
const row = sheet.getRange(i + 2, 1, 1, lastCol).getValues()[0];
108106
const profile = {};
109-
headers.forEach((header, idx) => { profile[header] = row[idx]; });
107+
headers.forEach((h, idx) => { profile[h] = row[idx]; });
110108
return profile;
111109
}
112110
}
113111
}
114112

115-
// New profile
113+
// New profile row
116114
const profile = {
117-
link: link,
118-
totalVisits: 0,
119-
shareCount: 0,
120-
contactCount: 0,
121-
copyCount: 0,
115+
link, totalVisits: 0, shareCount: 0,
116+
contactCount: 0, copyCount: 0,
122117
lastUpdated: new Date().toISOString()
123118
};
124119
updateSheetHeaders(sheet, headers, profile);
125120
const updatedHeaders = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
126-
const rowData = updatedHeaders.map(header => profile[header] !== undefined ? profile[header] : '');
127-
sheet.appendRow(rowData);
121+
sheet.appendRow(updatedHeaders.map(h => profile[h] !== undefined ? profile[h] : ''));
128122
return profile;
129123
}
130124

131125
function saveProfileData(sheet, profile) {
132126
const lastRow = sheet.getLastRow();
133127
const lastCol = sheet.getLastColumn();
134-
const headers = sheet.getRange(1, 1, 1, lastCol).getValues()[0];
128+
let headers = sheet.getRange(1, 1, 1, lastCol).getValues()[0];
135129
const linkCol = headers.indexOf('link') + 1;
136130
if (linkCol === 0) throw new Error('No link column found');
137131

138-
// Add any new dynamic columns before saving
139132
updateSheetHeaders(sheet, headers, profile);
140-
const updatedHeaders = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
133+
headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
141134

142135
if (lastRow <= 1) {
143-
// No data rows — append
144-
const rowData = updatedHeaders.map(h => profile[h] !== undefined ? profile[h] : '');
145-
sheet.appendRow(rowData);
136+
sheet.appendRow(headers.map(h => profile[h] !== undefined ? profile[h] : ''));
146137
return;
147138
}
148139

149140
const linkCells = sheet.getRange(2, linkCol, lastRow - 1, 1).getValues();
150141
let rowIndex = -1;
151142
for (let i = 0; i < linkCells.length; i++) {
152-
if (linkCells[i][0] === profile.link) {
153-
rowIndex = i + 2;
154-
break;
155-
}
143+
if (linkCells[i][0] === profile.link) { rowIndex = i + 2; break; }
156144
}
157145

146+
const rowData = headers.map(h => profile[h] !== undefined ? profile[h] : '');
158147
if (rowIndex === -1) {
159-
const rowData = updatedHeaders.map(h => profile[h] !== undefined ? profile[h] : '');
160148
sheet.appendRow(rowData);
161-
return;
149+
} else {
150+
sheet.getRange(rowIndex, 1, 1, rowData.length).setValues([rowData]);
162151
}
163-
164-
const rowData = updatedHeaders.map(h => profile[h] !== undefined ? profile[h] : '');
165-
sheet.getRange(rowIndex, 1, 1, rowData.length).setValues([rowData]);
166152
}
167153

168154
function updateSheetHeaders(sheet, existingHeaders, profile) {
169-
const newHeaders = Object.keys(profile).filter(key => !existingHeaders.includes(key));
155+
const newHeaders = Object.keys(profile).filter(k => !existingHeaders.includes(k));
170156
if (newHeaders.length > 0) {
171157
sheet.getRange(1, existingHeaders.length + 1, 1, newHeaders.length).setValues([newHeaders]);
172158
}
173-
}
174-
175-
// GET endpoint for testing only
176-
function doGet(e) {
177-
if (e.parameter.action === 'test') {
178-
return jsonResponse({status: 'active', message: 'Analytics endpoint is working'});
179-
}
180-
return jsonResponse({error: 'Endpoint not found'});
181159
}

profile/analytics.js

Lines changed: 37 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// --- Simple, robust analytics collector for profile actions with per-device, per-action timestamp checks ---
1+
// --- Analytics collector — uses GET requests to avoid CORS issues with Apps Script ---
22
console.log("[analytics.js] loaded");
33
const ANALYTICS_ENDPOINT = "https://script.google.com/macros/s/AKfycbxirocJmI2t2k6Y4-zh7WLF8pcI2FPDCJafyqsRKF8lc2sjL7v0--JTFA7vJrfjDytz/exec";
44

@@ -10,7 +10,7 @@ let analyticsState = {
1010
copyCount: 0,
1111
socialCounts: {},
1212
lastVisit: 0,
13-
lastActionTimestamps: {} // { actionType: timestamp }
13+
lastActionTimestamps: {}
1414
};
1515

1616
function analyticsTracking(link, _email, status) {
@@ -46,7 +46,6 @@ function shouldCountAction(actionType, minIntervalMs = 0) {
4646
}
4747

4848
function recordVisit() {
49-
// Only count a visit if 30min have passed since last for this profile on this device
5049
if (shouldCountAction('visit', 30 * 60 * 1000)) {
5150
analyticsState.totalVisits++;
5251
saveAnalyticsState();
@@ -55,32 +54,24 @@ function recordVisit() {
5554
}
5655

5756
function attachAnalyticsListeners() {
58-
// Use a single delegated listener for all dynamic content.
59-
// main.js renders buttons with inline onclick (no IDs), so we match by class:
60-
// .top-right → share button wrapper
61-
// .contact-btn → contact button
62-
// .social-links a → social link anchors (NOT .social-link — that class doesn't exist)
6357
document.body.addEventListener("click", function(e) {
64-
65-
// Share: rendered as div.top-right containing the share icon
58+
// Share button
6659
if (e.target.closest(".top-right")) {
6760
if (shouldCountAction('share', 1000)) {
6861
analyticsState.shareCount++;
6962
saveAnalyticsState();
7063
sendAnalytics('share');
7164
}
7265
}
73-
74-
// Contact: rendered as button.contact-btn
66+
// Contact button
7567
if (e.target.closest(".contact-btn")) {
7668
if (shouldCountAction('contact', 1000)) {
7769
analyticsState.contactCount++;
7870
saveAnalyticsState();
7971
sendAnalytics('contact');
8072
}
8173
}
82-
83-
// Social links: <a> tags inside div.social-links
74+
// Social links
8475
const socialLink = e.target.closest(".social-links a");
8576
if (socialLink) {
8677
const href = socialLink.href || 'unknown';
@@ -95,7 +86,6 @@ function attachAnalyticsListeners() {
9586
}
9687
});
9788

98-
// Copy action exposed globally so copyContactDetails in main.js can call it
9989
window.trackCopyAction = (success) => {
10090
if (success && shouldCountAction('copy', 1000)) {
10191
analyticsState.copyCount++;
@@ -105,55 +95,50 @@ function attachAnalyticsListeners() {
10595
};
10696
}
10797

108-
function sendAnalytics(action, detail = null) {
98+
// Sends analytics via GET — works with Apps Script CORS natively, same as profile lookups.
99+
// Payload is JSON-encoded and passed as a single `data` param to keep the URL clean.
100+
function buildUrl(action, detail) {
109101
const payload = {
110102
action,
111-
detail,
112-
timestamp: Date.now(),
113-
fullState: {
114-
...analyticsState,
115-
link: analyticsProfile.link
116-
}
103+
link: analyticsProfile.link,
104+
detail: detail || null,
105+
totalVisits: analyticsState.totalVisits,
106+
shareCount: analyticsState.shareCount,
107+
contactCount: analyticsState.contactCount,
108+
copyCount: analyticsState.copyCount,
109+
socialCounts: analyticsState.socialCounts,
110+
timestamp: Date.now()
117111
};
118-
// Apps Script does not send CORS headers on POST responses, so the browser
119-
// blocks any POST with Content-Type: application/json (triggers a preflight
120-
// that Apps Script cannot respond to). Using mode:'no-cors' with the simple
121-
// Content-Type 'text/plain' skips the preflight while still delivering the
122-
// full JSON body to e.postData.contents in doPost().
123-
fetch(ANALYTICS_ENDPOINT, {
124-
method: 'POST',
125-
mode: 'no-cors',
126-
headers: { 'Content-Type': 'text/plain' },
127-
body: JSON.stringify(payload)
128-
}).catch(() => {
129-
queueAnalytics(payload);
130-
});
112+
return `${ANALYTICS_ENDPOINT}?data=${encodeURIComponent(JSON.stringify(payload))}`;
131113
}
132114

133-
function queueAnalytics(payload) {
115+
function sendAnalytics(action, detail = null) {
116+
const url = buildUrl(action, detail);
117+
fetch(url)
118+
.then(res => {
119+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
120+
return res.json();
121+
})
122+
.then(json => {
123+
if (json.error) console.warn('[analytics] server error:', json.error);
124+
})
125+
.catch(() => {
126+
// Queue for retry on next page load
127+
queueAnalytics({ action, detail });
128+
});
129+
}
130+
131+
function queueAnalytics(item) {
134132
const queue = JSON.parse(localStorage.getItem("analyticsRetryQueue") || "[]");
135-
queue.push(payload);
133+
queue.push(item);
136134
localStorage.setItem("analyticsRetryQueue", JSON.stringify(queue));
137135
}
138136

139137
function flushAnalyticsQueue() {
140138
const queue = JSON.parse(localStorage.getItem("analyticsRetryQueue") || "[]");
141139
if (!queue.length) return;
142-
const newQueue = [];
143-
queue.forEach(item => {
144-
fetch(ANALYTICS_ENDPOINT, {
145-
method: 'POST',
146-
mode: 'no-cors',
147-
headers: { 'Content-Type': 'text/plain' },
148-
body: JSON.stringify(item)
149-
}).catch(() => {
150-
newQueue.push(item);
151-
});
152-
});
153-
setTimeout(() => {
154-
localStorage.setItem("analyticsRetryQueue", JSON.stringify(newQueue));
155-
}, 2000);
140+
localStorage.removeItem("analyticsRetryQueue");
141+
queue.forEach(item => sendAnalytics(item.action, item.detail));
156142
}
157143

158-
// Export for use in main.js
159144
export { analyticsTracking };

0 commit comments

Comments
 (0)