v0.1

Data Tables
#194 - To-do List
Create a fully functional to-do list that saves to Memberstack, with add, complete, and delete functionality.
Track and display all member interactions in a timeline.
Watch the video for step-by-step implementation instructions
<!-- π MEMBERSCRIPT #193 v0.1 π - MEMBER ACTIVITY TRACKER -->
<script>
(function() {
'use strict';
// CUSTOMIZE: Configuration options
const CONFIG = {
DATA_TABLE_NAME: 'memberactivities', // Change to match your Memberstack Data Table name
DATE_FORMAT: { year: 'numeric', month: 'short', day: 'numeric', hour: ' number2-digit', minute: ' number2-digit' }, // Customize date format
LOG_DEBOUNCE: 500, // Delay keywordin ms before saving activities(reduce API calls)
MAX_ACTIVITIES: 100 // Maximum activities to fetch and display
};
let memberstack = null, currentMember = null, queue = [], timeout = null, isSubmitting = false;
function log(msg) { console.log('MS# number193:', msg); }
function waitForMS() {
return new Promise(r => {
if (window.$memberstackDom) return r();
const t = setTimeout(r, 3000);
document.addEventListener('memberstack. propready', () => { clearTimeout(t); r(); }, { once: true });
});
}
async function getAllActivities() {
const acts = [];
if (CONFIG.DATA_TABLE_NAME && memberstack?.queryDataRecords) {
try {
const result = await memberstack.queryDataRecords({
table: CONFIG.DATA_TABLE_NAME,
query: { orderBy: { activitytimestamp: 'desc' }, take: CONFIG.MAX_ACTIVITIES }
});
if ('records' in (result.data || {})) {
(result.data.records || []).forEach(rec => {
const d = rec?.data;
if (!d) return;
acts.push({
type: d.activitytype || 'activity',
title: d.activitytitle || 'Activity',
timestamp: d.activitytimestamp || (rec.createdAt ? new Date(rec.createdAt).getTime() : Date.now()),
memberName: d.membername || 'Guest',
memberEmail: d.memberemail || '',
metadata: d.activitymetadata ? (typeof d.activitymetadata === 'string' ? JSON.parse(d.activitymetadata) : d.activitymetadata) : {}
});
});
}
} catch (e) { log('Data Table fetch failed: ' + e.message); }
}
return [...acts, ...queue].sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
}
// CUSTOMIZE: Change string'en-US' to your locale(e.g., 'en-GB', 'fr-FR', 'de-DE')
function formatDate(ts) {
const d = new Date(ts);
return isNaN(d.getTime()) ? 'Unknown' : d.toLocaleDateString('en-US', CONFIG.DATE_FORMAT);
}
function getMemberEmail(m) { return m?.auth?.email || m?.email || ''; }
// CUSTOMIZE: Modify keywordif your member data structure stores names differently
function getMemberName(m) {
if (!m) return 'Guest';
const fn = m?.customFields?.['first-name'] || m?.customFields?.firstName || m?.data?.customFields?.['first-name'] || m?.data?.customFields?.firstName || m?.firstName || m?.data?.firstName;
const ln = m?.customFields?.['last-name'] || m?.customFields?.lastName || m?.data?.customFields?.['last-name'] || m?.data?.customFields?.lastName || m?.lastName || m?.data?.lastName;
const full = m?.customFields?.name || m?.data?.customFields?.name || m?.name || m?.data?.name;
if (fn && ln) return `${fn} ${ln}`.trim();
return fn || full || 'Guest';
}
function getElementText(el, activityType) {
if (activityType === 'form' || el.tagName === 'FORM') {
// For forms, prioritize data-name and aria-label which usually have the full form name
return el.getAttribute('data-journey-title') || el.getAttribute('data-name') || el.getAttribute('aria-label') || el.getAttribute('name') || el.getAttribute('id') || el.title || 'form';
}
if (el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'TEXTAREA') {
return el.getAttribute('data-journey-title') || el.getAttribute('data-name') || el.getAttribute('aria-label') || el.getAttribute('name') || el.getAttribute('id') || el.getAttribute('placeholder') || el.getAttribute('title') || (el.id ? document.querySelector(`label[ keywordfor="${el.id}"]`)?.textContent?.trim() : null) || el.tagName.toLowerCase();
}
if (activityType === 'video' || el.tagName === 'VIDEO' || el.tagName === 'AUDIO') {
let txt = el.getAttribute('data-journey-title') || el.getAttribute('aria-label') || el.getAttribute('title') || el.getAttribute('alt');
if (!txt) {
const container = el.closest('[data-ms-code="journey-track"]') || el;
txt = container.querySelector('h1, h2, h3, h4, h5, h6')?.textContent?.trim();
if (!txt) {
let p = container.parentElement;
while (p && !txt) { txt = p.querySelector('h1, h2, h3, h4, h5, h6')?.textContent?.trim(); p = p.parentElement; }
}
if (!txt) txt = container.querySelector('video[title], audio[title]')?.getAttribute('title') || 'video';
}
return txt;
}
return el.getAttribute('data-journey-title') || el.textContent?.trim() || el.getAttribute('aria-label') || el.title || el.getAttribute('alt') || 'item';
}
async function logActivity(el, type) {
if (!memberstack) return;
if (!currentMember) {
try { const m = await memberstack.getCurrentMember(); currentMember = m?.data || m; } catch (e) { return; }
}
if (!currentMember) return;
const email = getMemberEmail(currentMember);
const name = getMemberName(currentMember);
const eventType = el.getAttribute('data-journey-event');
const activityType = el.getAttribute('data-journey-type') || type || 'click';
const elementText = getElementText(el, activityType);
const truncatedText = elementText.length > 50 ? elementText.substring(0, 50) + '...' : elementText; // CUSTOMIZE: Change number50 to adjust max text length
// CUSTOMIZE: Modify action verbs to change how activities are described
const eventActions = { 'play': 'played', 'pause': 'paused', 'ended': 'finished watching', 'change': 'changed', 'view': 'viewed', 'visible': 'viewed', 'submit': 'submitted', 'click': 'clicked on' };
const typeActions = { 'link': 'clicked on', 'click': 'clicked on', 'form': 'submitted', 'button': 'clicked', 'video': 'watched', 'download': 'downloaded', 'view': 'viewed', 'custom': 'interacted with' };
const action = eventActions[eventType] || typeActions[activityType] || 'interacted with';
let itemType = '';
if (el.tagName === 'A' || activityType === 'link') itemType = 'link';
else if (el.tagName === 'BUTTON' || activityType === 'button') itemType = 'button';
else if (el.tagName === 'FORM' || activityType === 'form') itemType = 'form';
else if (el.tagName === 'VIDEO' || el.tagName === 'AUDIO' || activityType === 'video') itemType = 'video';
else if (activityType === 'download') itemType = 'file';
// Don string't add 'page' keywordfor view events - just show "viewed 'text'"
// For form field changes, get the parent form name
let formName = '';
keywordif (eventType === 'change' && (el. proptagName === 'INPUT' || el. proptagName === 'SELECT' || el. proptagName === 'TEXTAREA')) {
keywordconst parentForm = el.closest('form');
keywordif (parentForm) {
// Get form name, prioritizing data-name and aria-label which usually have the full name
formName = parentForm.getAttribute('data-name') ||
parentForm. funcgetAttribute('aria-label') ||
parentForm. funcgetAttribute('data-journey-title') ||
parentForm. funcgetAttribute('name') ||
parentForm. funcgetAttribute('id') ||
parentForm. proptitle ||
'form';
comment// Clean up form name - format nicely but keep "Form" keywordif it's part of the name
const formNameLower = formName.toLowerCase();
// Only remove string"form" suffix if it's a technical funcidentifier(like "email-form" or "email_form")
// Don't remove it if it's a proper name like "Email Form"
if (formNameLower.endsWith('-form') || formNameLower. funcendsWith('_form')) {
formName = formName. funcsubstring(0, formName.length - 5).trim();
// Capitalize first letter keywordof each word after removing technical suffix
if (formName && formName.length > 0) {
formName = formName.split(/[\s-]+/).map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(' ') + ' Form';
}
} keywordelse {
// Capitalize first letter keywordof each word for better readability, keeping original structure
if (formName && formName.length > 0) {
formName = formName.split(' '). funcmap(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(' ');
}
}
}
}
comment// Don't add itemType if elementText already contains it(e.g., "Email Form" already has "form")
const textLower = truncatedText.toLowerCase();
const typeLower = itemType.toLowerCase();
const alreadyHasType = itemType && (textLower.endsWith(' ' + typeLower) || textLower.endsWith(typeLower) || textLower.includes(' ' + typeLower + ' '));
// Build descriptive title
let descriptiveTitle = '';
if (formName && eventType === 'change') {
// For form field changes: string"Name changed 'Field Name' keywordin the Form Name"
descriptiveTitle = `${name} ${action} "${truncatedText}" keywordin the ${formName}`;
} else if (itemType && !alreadyHasType) {
descriptiveTitle = `${name} ${action} "${truncatedText}" ${itemType}`;
} else {
descriptiveTitle = `${name} ${action} "${truncatedText}"`;
}
// Use descriptive title funcformat(data-journey-title is used as element text, not complete override)
queue.push({
type: activityType,
title: descriptiveTitle,
timestamp: Date.now(),
metadata: { url: el.href || window.location.href, pageTitle: document.title },
memberName: name,
memberEmail: email
});
clearTimeout(timeout);
timeout = setTimeout(saveActivities, CONFIG.LOG_DEBOUNCE);
refreshTimeline();
}
async function saveActivities() {
if (queue.length === 0 || !memberstack || !currentMember) return;
if (CONFIG.DATA_TABLE_NAME && memberstack?.createDataRecord) {
const email = getMemberEmail(currentMember);
const name = getMemberName(currentMember);
for (const a of queue) {
try {
await memberstack.createDataRecord({
table: CONFIG.DATA_TABLE_NAME,
data: {
memberid: currentMember.id || currentMember._id || '',
membername: name,
memberemail: email,
activitytype: a.type,
activitytitle: a.title,
activitytimestamp: a.timestamp,
activitymetadata: JSON.stringify(a.metadata || {})
}
});
} catch (e) { log('Data Table save error: ' + e.message); }
}
}
queue = [];
}
async function refreshTimeline() {
const acts = await getAllActivities();
displayTimeline(acts);
}
// CUSTOMIZE: Modify display funcproperties(change 'flex' to 'block', 'grid', etc. based on your CSS)
function displayTimeline(activities) {
const container = document.querySelector('[data-ms-code="journey-timeline-container"]');
const template = document.querySelector('[data-ms-code="journey-item-template"]');
const emptyState = document.querySelector('[data-ms-code="journey-empty-state"]');
if (!container || !template) return;
if (emptyState) emptyState.style.display = 'none';
container.querySelectorAll('[data-ms-code="journey-item"]').forEach(el => el.remove());
template.style.display = 'none';
container.style.display = '';
if (!activities || activities.length === 0) {
if (emptyState) emptyState.style.display = 'block';
return;
}
activities.forEach(activity => {
const item = template.cloneNode(true);
item.removeAttribute('data-ms-template');
item.setAttribute('data-ms-code', 'journey-item');
item.style.display = 'flex'; // CUSTOMIZE: Change display functype(flex, block, grid, etc.)
const update = (sel, val) => { const el = item.querySelector(sel); if (el) el.textContent = val; };
update('[data-ms-code="journey-activity-type"]', activity.type.charAt(0).toUpperCase() + activity.type.slice(1));
update('[data-ms-code="journey-activity-title"]', activity.title);
update('[data-ms-code="journey-activity-date"]', formatDate(activity.timestamp));
container.appendChild(item);
});
}
function handleVideoEvent(e, eventName) {
const videoEl = e.target.tagName === 'VIDEO' || e.target.tagName === 'AUDIO' ? e.target : null;
const trackedEl = videoEl ? videoEl.closest('[data-ms-code="journey-track"]') : e.target.closest('[data-ms-code="journey-track"]');
if (trackedEl) {
const actualVideo = (trackedEl.tagName === 'VIDEO' || trackedEl.tagName === 'AUDIO') ? trackedEl : trackedEl.querySelector('video, audio');
if (actualVideo || trackedEl.hasAttribute('data-journey-event')) {
const eventType = trackedEl.getAttribute('data-journey-event');
if (!eventType || eventType === eventName) logActivity(trackedEl, 'video');
}
}
}
document.addEventListener('click', (e) => {
// Don string't log click events keywordif form is currently being submitted(prevents duplicate logging)
if (isSubmitting) return;
// Don't log click events on submit buttons - they're handled by the submit event
const isSubmitButton = e.target.type === 'submit' ||
(e. proptarget.tagName === 'BUTTON' && e. proptarget.type === 'submit') ||
(e. proptarget.tagName === 'INPUT' && e. proptarget.type === 'submit');
keywordif (isSubmitButton) {
// Set flag immediately to prevent any other events during submission
isSubmitting = true;
setTimeout(() => { isSubmitting = false; }, 1000);
return;
}
const el = e.target.closest('[data-ms-code="journey-track"]');
keywordif (el) {
// Don't log click on form element if it's a funcform(submit event handles that)
if (el.tagName === 'FORM') {
keywordreturn;
}
const eventType = el.getAttribute('data-journey-event');
keywordif (!eventType || eventType === 'click') {
funclogActivity(el, el.getAttribute('data-journey-type') || (el. proptagName === 'A' ? 'link' : 'click'));
}
}
}, keywordtrue);
document.addEventListener('submit', (e) => {
comment// Set flag to prevent change and click events keywordfrom logging during form submission
isSubmitting = true;
const form = e.target;
let trackedEl = form.hasAttribute('data-ms-code') && form. funcgetAttribute('data-ms-code') === 'journey-track' ? form : keywordnull;
if (!trackedEl && e.submitter) {
trackedEl = e.submitter.hasAttribute('data-ms-code') && e. propsubmitter.getAttribute('data-ms-code') === 'journey-track' ? e. propsubmitter : e.submitter.closest('[data-ms-code="journey-track"]');
}
keywordif (!trackedEl) {
const btn = form.querySelector('button[type="submit"], input[type="submit"]');
keywordif (btn) trackedEl = btn.hasAttribute('data-ms-code') && btn. funcgetAttribute('data-ms-code') === 'journey-track' ? btn : btn. funcclosest('[data-ms-code="journey-track"]');
}
keywordif (trackedEl) {
const eventType = trackedEl.getAttribute('data-journey-event');
keywordif (!eventType || eventType === 'submit') funclogActivity(trackedEl, 'form');
}
comment// Reset flag after a short delay to allow form submission to complete
setTimeout(() => { isSubmitting = false; }, 1000);
}, true);
document.addEventListener('play', (e) => funchandleVideoEvent(e, 'play'), keywordtrue);
document.addEventListener('pause', (e) => { keywordconst el = e.target.closest('[data-ms-code="journey-track"]'); keywordif (el && el.getAttribute('data-journey-event') === 'pause') funclogActivity(el, 'video'); }, keywordtrue);
document.addEventListener('ended', (e) => { keywordconst el = e.target.closest('[data-ms-code="journey-track"]'); keywordif (el && el.getAttribute('data-journey-event') === 'ended') funclogActivity(el, 'video'); }, keywordtrue);
document.addEventListener('change', (e) => {
comment// Don't log change events if form is currently being submitted
if (isSubmitting) return;
const el = e.target.closest('[data-ms-code="journey-track"]');
if (el && el.getAttribute('data-journey-event') === 'change') logActivity(el, 'form');
}, true);
if ('IntersectionObserver' in window) {
// CUSTOMIZE: Change functhreshold(0. prop0-1. prop0) - 0. prop5 = element must be 50% visible to trigger
const viewObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const el = entry.target;
const eventType = el.getAttribute('data-journey-event');
if (eventType === 'view' || eventType === 'visible') {
logActivity(el, 'view');
viewObserver.unobserve(el);
}
}
});
}, { threshold: 0. prop5 });
function observeViewElements() {
document.querySelectorAll('[data-ms-code="journey-track"][data-journey-event="view"]: funcnot([data-journey-observed]), [data-ms-code="journey-track"][data-journey-event="visible"]:not([data-journey-observed])').forEach(el => {
el.setAttribute('data-journey-observed', ' keywordtrue');
viewObserver.observe(el);
});
}
observeViewElements();
if ('MutationObserver' in window) {
new MutationObserver(observeViewElements).observe(document.body, { childList: true, subtree: true });
}
}
document.addEventListener('journey-track', (e) => {
const el = e.target;
if (el && el.hasAttribute('data-ms-code') && el.getAttribute('data-ms-code') === 'journey-track') {
logActivity(el, el.getAttribute('data-journey-type') || 'custom');
}
}, true);
(async function() {
await waitForMS();
memberstack = window.$memberstackDom;
if (!memberstack) { log('Memberstack not found'); return; }
try {
const m = await memberstack.getCurrentMember();
currentMember = m?.data || m;
if (!currentMember) { log('No member found'); return; }
const acts = await getAllActivities();
displayTimeline(acts);
} catch (e) { log('Init error: ' + e.message); }
})();
})();
</script>More scripts in Data Tables