';
}
// "Facebook Graphs" toggle at the bottom of the card: same-origin iframe of THIS page stripped
// down to its LEAD GENERATION (Facebook Data) section (#leadGenFbBody, Overview tab), so the 5
// Chart.js graphs track update_dashboard_all_data.py with no second copy. Collapsed by default;
// the iframe only loads on first open and the open state survives the 5-min re-render only.
var leadGenOpen = false;
function buildLeadGenToggle() {
return '
';
}
// Hide every sibling on the path from #leadGenFbBody up to , then force the section itself
// visible (the source keeps it display:none until its own toggle). Runs inside the same-origin iframe.
function isolateLeadGenSection(frame) {
var doc;
try { doc = frame.contentDocument; } catch (e) { console.error('KPIs tab: Facebook Graphs iframe document unreachable', e); return; }
if (!doc) return;
var section = doc.getElementById(LEADGEN_SECTION_ID);
if (!section) { console.error('KPIs tab: #' + LEADGEN_SECTION_ID + ' missing from this page'); return; }
var node = section;
while (node && node !== doc.body) {
var parent = node.parentElement;
if (!parent) break;
Array.prototype.forEach.call(parent.children, function (sib) {
if (sib === node || sib.tagName === 'SCRIPT' || sib.tagName === 'STYLE') return;
sib.style.setProperty('display', 'none', 'important');
});
node.style.setProperty('display', 'block', 'important');
node = parent;
}
doc.body.style.setProperty('padding', '8px', 'important');
doc.body.style.setProperty('margin', '0', 'important');
var fit = function () {
var win = doc.defaultView;
if (win && win.Chart) {
section.querySelectorAll('canvas').forEach(function (el) {
var chart = win.Chart.getChart(el);
if (chart) chart.resize();
});
}
frame.style.height = (doc.documentElement.scrollHeight + 16) + 'px';
};
LEADGEN_FIT_DELAYS_MS.forEach(function (ms) { setTimeout(fit, ms); });
}
function loadLeadGenFrame() {
var frame = document.getElementById('kpis-leadgen-frame');
if (!frame || frame.getAttribute('src')) return;
frame.addEventListener('load', function () { isolateLeadGenSection(frame); });
frame.setAttribute('src', frame.getAttribute('data-src'));
}
function buildFbCard() {
var inner;
if (!pageData.MONTHS || !pageData.MONTHS.length) {
inner = '
Facebook data unavailable (no MONTHS in this page)
Data: Meta Ads Full History → Monthly Summary Table (update_dashboard_all_data.py) · ' + pageData.MONTHS.length + ' months
';
}
return '
📘 Facebook Data
' + inner + buildLeadGenToggle() + '
';
}
// โโ Calendly Appts card (right of Facebook Data) โโโโโโโโโโโโโโโโโโโโโโโโโโโ
var CALENDLY_HEADING = hoverHeading('calendly', 'Calendly Appts (ALL)', 'Hover for the monthly Calendly appointments chart + breakdown by clinic');
var CALENDLY_FB_HEADING = hoverHeading('fb_results', 'Calendly Appts (via FB) aka RESULTS', 'Hover for the last 12 months of Facebook results (leads)');
var CALENDLY_THIS_MONTH_LABEL = 'This Month';
// Facebook Results (leads) last month / this month โ same MONTHS rows as the Facebook Data card.
function calendlyFbSection() {
var metric = FB_METRICS.fb_results;
var lt = fbLastTwo(metric);
var cell = function (row, suffix) {
return row ? value(esc(metric.fmt(row.value)), COLORS.blue, esc(row.label) + suffix) : value('—', COLORS.muted, 'no prior month');
};
if (!lt.cur) return value('—', COLORS.muted, 'no MONTHS in this page');
return split('Last Month', cell(lt.prev, ''), 'This Month', cell(lt.cur, ' ยท to date'));
}
function nextAvailableHtml(data, errors) {
var v;
var n = data.next_available;
if (errors && 'next_available' in errors) v = unavailable(errors, 'next_available');
else if (!n) v = value('None', COLORS.red, 'no slots in next 28 days');
else {
var dayLabel = String(n.label || '').split(' ').slice(0, 3).join(' ');
var days = n.days_until === 1 ? '1 day' : n.days_until + ' days';
v = value(esc(dayLabel), COLORS.green, esc(n.time) + ' AWST · in ' + esc(days), n.label);
}
return '
When is next available Calendly date
' + v + '
';
}
function calendlyAllSection(data, errors) {
var c = data.calendly;
if (!c || !c.this_month) return unavailable(errors, 'calendly');
var last = c.last_month ? value(esc(c.last_month.count), COLORS.blue, esc(c.last_month.short)) : value('—', COLORS.muted, 'no prior month');
var t = c.this_month;
var thisHtml = value(esc(t.count) + ' (~' + esc(t.projected) + ')', COLORS.blue,
esc(t.short) + ' · proj. end of month · day ' + esc(t.days_elapsed) + '/' + esc(t.days_in_month));
return split('Last Month', last, CALENDLY_THIS_MONTH_LABEL, thisHtml);
}
function buildCalendlyCard(data) {
var body;
if (!data) {
body = calendlyFbSection() + innerSection(CALENDLY_HEADING, value('—', COLORS.muted, 'marketing-journey-data.js unavailable'));
} else {
var errors = data.errors || {};
body = calendlyFbSection() + innerSection(CALENDLY_HEADING, calendlyAllSection(data, errors)) + nextAvailableHtml(data, errors);
}
return '
';
}
// โโ Marketing Journey Board tiles โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
var GP_HEADING = hoverHeading('gp', 'GP Referrals (ALL)', 'Hover for the weekly GP referrals chart');
var GP_FB_EST_HEADING = 'Est GP referrals via FB';
var CURRENT_TMS_HEADING = hoverHeading('current_tms', 'Current TMS numbers', 'Hover for the weekly current TMS numbers chart (last year)');
var MAPPING_HEADING = hoverHeading('mapping', 'Total Mapped patients', 'Hover for the weekly mapping appointments chart (last year)');
var WAITLIST_HEADING = hoverHeading('waitlist', '📂 Waitlist Journey Board', 'Hover for the last 20 Monday snapshots (NPF/CHF sent/returned, screening, mapping)');
var WAITLIST_COLUMNS = [['NPF/CHF Sent', 'npf_chf_sent'], ['NPF/CHF Returned', 'npf_chf_returned'], ['Screening Booked', 'screening_booked'], ['Screening Completed', 'screening_completed'], ['Mapping Booked', 'mapping_booked']];
var WAITLIST_TILE_ID = 'kpis-waitlist-tile';
var FB_EST_LABEL = 'Estimated no of GP referrals from FB (last month):';
// Est GP referrals from FB = conversion ratio ร Facebook Results last month (this page's MONTHS).
// Same number tops the GP tile; shown under the ratio in the Conversion Ratio tile.
function fbEstimate(data) {
var cv = data && data.conversion;
var ratio = cv ? parseFloat(String(cv.ratio).replace('%', '')) : NaN;
var prev = fbLastTwo(FB_METRICS.fb_results).prev;
var results = prev ? prev.value : NaN;
if (!isFinite(ratio) || !isFinite(results)) return null;
return { refs: (ratio / 100) * results, ratio: cv.ratio, results: results, month: prev.label };
}
function fbEstimateValue(data) {
var e = fbEstimate(data);
if (!e) return value('—', COLORS.muted, 'needs ratio + FB Results last month');
return value(esc(e.refs.toFixed(1)), COLORS.purple, esc(e.ratio) + ' × ' + esc(e.results) + ' FB results (' + esc(e.month) + ')');
}
function conversionTile(data, errors) {
var title = 'Conversion Ratio Calendly → GP Referral (CRM/Ruth)';
var cv = data.conversion;
var est = '
' + FB_EST_LABEL + '
' + fbEstimateValue(data) + '
';
if (!cv || !cv.ratio) return tile(title, COLORS.purple, unavailable(errors, 'conversion') + est);
var sub = 'as of ' + esc(cv.as_of || '?');
if (cv.gp_referrals_60d && cv.new_contacts_60d) sub = esc(cv.gp_referrals_60d) + '/' + esc(cv.new_contacts_60d) + ' over 60d · ' + sub;
return tile(title, COLORS.purple, value(esc(cv.ratio), COLORS.purple, sub) + est);
}
function gpTile(data, errors) {
var estTop = half('Last Month', fbEstimateValue(data), false);
var g = data.gp_referrals;
if (!g || !g.this_week) return tile(GP_FB_EST_HEADING, COLORS.amber, estTop + innerSection(GP_HEADING, unavailable(errors, 'gp_referrals')));
var week = function (w) { return w ? value(esc(w.referrals), COLORS.amber, 'week of ' + esc(w.date)) : value('—', COLORS.muted, 'no prior week'); };
var month = function (m) { return m ? value(esc(m.referrals), COLORS.amber, 'wks ' + esc(m.from) + ' – ' + esc(m.to)) : value('—', COLORS.muted, 'needs 8 weekly rows'); };
var monthRow = (g.this_month || g.last_month)
? split('Last Month', month(g.last_month), 'This Month', month(g.this_month)) + ''
: '';
return tile(GP_FB_EST_HEADING, COLORS.amber, estTop + innerSection(GP_HEADING, monthRow + split('Last Week', week(g.last_week), 'This Week', week(g.this_week))));
}
function weekSplit(section, field, data, errors) {
var s = data[section];
if (!s || !s.this_week) return unavailable(errors, section);
var week = function (w) { return w ? value(esc(w[field]), COLORS.amber, 'week of ' + esc(w.date)) : value('—', COLORS.muted, 'no prior week'); };
return split('Last Week', week(s.last_week), 'This Week', week(s.this_week));
}
// Total Mapped patients tops this tile; Current TMS numbers sits under it with its own heading.
function mappedTmsTile(data, errors) {
return tile(MAPPING_HEADING, COLORS.amber, weekSplit('mapping', 'mapped', data, errors) + innerSection(CURRENT_TMS_HEADING, weekSplit('current_tms', 'patients', data, errors)));
}
function waitlistTile(data, errors) {
var w = data.waitlist;
var body;
if (!w) body = unavailable(errors, 'waitlist');
else {
var cells = WAITLIST_COLUMNS.map(function (s, i) {
var val = (w[s[1]] == null) ? '—' : esc(w[s[1]]);
return '
';
}
// Waitlist tile fills the empty slots to the right of the last small tile; full width when fewer
// than WAITLIST_MIN_SPAN slots are free. Column count depends on width โ ResizeObserver.
var waitlistObserver = null;
function fitWaitlist(grid) {
var t = document.getElementById(WAITLIST_TILE_ID);
if (!t || t.parentElement !== grid) return;
var cols = getComputedStyle(grid).gridTemplateColumns.split(' ').filter(function (c) { return /px$/.test(c); }).length;
if (!cols) return; // collapsed / hidden tab โ observer re-fires once visible
var before = Array.prototype.indexOf.call(grid.children, t);
var free = (cols - (before % cols)) % cols;
t.style.gridColumn = free >= WAITLIST_MIN_SPAN ? 'span ' + free : '1 / -1';
}
function observeWaitlist() {
var t = document.getElementById(WAITLIST_TILE_ID);
if (!t || typeof ResizeObserver === 'undefined') return;
var grid = t.parentElement;
if (waitlistObserver) waitlistObserver.disconnect();
waitlistObserver = new ResizeObserver(function () { fitWaitlist(grid); });
waitlistObserver.observe(grid);
fitWaitlist(grid);
}
function buildJourneyBoard(data) {
var body;
if (!data) {
body = 'Marketing journey data unavailable (marketing-journey-data.js missing)';
} else {
var errors = data.errors || {};
body = '
';
}
// โโ ๐ง Current TMS Patients block โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function buildTmsBlock(data) {
var title = '🧠 Current TMS Patients';
var t = data && data.tms_patients;
if (!t || !Array.isArray(t.rows) || !t.rows.length || typeof t.average !== 'number') {
return '
';
var body = rows.map(function (r) {
var asOf = waitlistAsOf(r);
var monday = isoDate(r.monday);
var cells = WAITLIST_COLUMNS.map(function (c) {
var v = r.values ? r.values[c[1]] : null;
return '
Board state at 00:00 AWST each Monday. Amber as-of = nearest Drive revision is more than a week older than that Monday.
';
}
// ๐ฅ Calendly appointments by clinic (month rows ร clinic columns) from calendly_clinics in
// marketing-journey-data.js (tools/calendly_clinic_breakdown.py). Rendered under the monthly chart.
function calendlyClinicTable(d) {
var c = d && d.calendly_clinics;
var errors = (d && d.errors) || {};
var title = '
🏥 Appointments by clinic (booking question: preferred location)
';
if (!c || !Array.isArray(c.rows) || !c.rows.length || !Array.isArray(c.clinics)) {
return title + '
Clinic = invitee answer to the Calendly booking question; Unknown = no answer. Calendly API via tools/calendly_clinic_breakdown.py · refreshed ' + esc(String(c.generated_at || '').replace('T', ' ').slice(0, 16)) + '
';
}
function calendlyPopover(months, d) {
var values = months.map(function (m) { return m.appointments; });
var completed = values.slice(0, -1);
var avg = average(completed.length ? completed : values);
var now = new Date(), day = now.getDate(), days = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
var current = values[values.length - 1];
var projected = day > 0 ? Math.round(current * days / day) : current;
var onTrack = projected >= Math.round(avg);
var colour = onTrack ? COLORS.green : COLORS.red;
var strip = '
' +
(onTrack ? '🟢 ▲' : '🔴 ▼') + ' ' + esc(months[months.length - 1].month) + ': ' + current + ' so far (day ' + day + '/' + days + ') → projected ' + projected + ' | Avg (completed months): ' + Math.round(avg) + ' — on track to finish ' + (onTrack ? 'ABOVE' : 'BELOW') + ' average
';
var rows = months.map(function (m) { return { label: m.month, value: m.appointments }; });
return strip + barChartSvg(rows, { unit: 'appts', fmt: function (v) { return String(Math.round(v)); }, label: 'Calendly appointments per month with average line' }) + calendlyClinicTable(d);
}
var MIRRORS = {
calendly: { title: '📅 Calendly Appointments (Monthly)', provenance: 'Data: CALENDLY_MONTHLY (update_dashboard_calendly.py)',
html: function (d) { var m = pageData.CALENDLY_MONTHLY; if (!m || !m.length) throw new Error('no CALENDLY_MONTHLY in this page'); return calendlyPopover(m, d); } },
gp: { title: '🩺 Weekly GP Referrals', provenance: 'Data: GP_REFERRALS (update_dashboard_gp_referrals.py)',
html: function () {
var g = pageData.GP_REFERRALS; if (!g || !g.length) throw new Error('no GP_REFERRALS in this page');
var rows = g.map(function (r) { return { date: r.date, total: r.referrals }; });
var avg = average(rows.map(function (r) { return r.total; }));
return weeklyChartSvg(rows, avg, { unit: 'referrals', label: 'Weekly GP referrals with average line' }) + legend(Math.round(avg)) + weeklyVerdict(rows, avg, 'referrals');
} },
mapping: { title: '🗺️ Weekly Mapping Appointments (last year)', provenance: 'Data: Neuralia TMS Action Log → 3.TotalReferrals col C',
html: function (d) { var m = d.mapping; if (!m || !m.rows || !m.rows.length) throw new Error('no mapping data in marketing-journey-data.js'); return weeklyChartSvg(m.rows, m.average, { unit: 'mapping appts', label: 'Weekly mapping appointments with average line' }) + legend(Math.round(m.average)) + weeklyVerdict(m.rows, m.average, 'mapping appts'); } },
current_tms: { title: '🧠 Weekly Current TMS Numbers (last year)', provenance: 'Data: Neuralia TMS Action Log → 1.CurrentTMSnumbers col C',
html: function (d) { var m = d.current_tms; if (!m || !m.rows || !m.rows.length) throw new Error('no current TMS data in marketing-journey-data.js'); return weeklyChartSvg(m.rows, m.average, { unit: 'TMS patients', label: 'Weekly current TMS numbers with average line' }) + legend(Math.round(m.average)) + weeklyVerdict(m.rows, m.average, 'TMS patients'); } },
waitlist: { title: '📂 Waitlist Journey Board — last 20 Mondays', provenance: 'Data: data/exports/waitlist-journey-history.json (waitlist_journey_history.py)',
html: function (d) { var h = d.waitlist_history; if (!h || !h.rows || !h.rows.length) throw new Error('no waitlist history in marketing-journey-data.js'); return waitlistHistoryTable(h.rows); } }
};
Object.keys(FB_METRICS).forEach(function (key) {
var metric = FB_METRICS[key];
MIRRORS[key] = { title: metric.title, provenance: 'Data: MONTHS (update_dashboard_all_data.py)',
html: function () {
var rows = fbRows(metric); if (!rows.length) throw new Error('no MONTHS in this page');
var cur = rows[rows.length - 1], prev = rows[rows.length - 2];
return barChartSvg(rows, metric) +
'
'; }
}
var popover = null;
function attachPopover(root) {
if (popover || !global.CreativeStatusExplainer) return;
popover = global.CreativeStatusExplainer.attach({
root: root, selector: '.kp-hover', width: 680,
render: function (el) { var key = el.getAttribute('data-mirror'); return MIRRORS[key] ? popoverHtml(key) : null; }
});
}
// โโ data loading + render loop โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
var journeyData = null;
function loadJourneyData(callback) {
var script = document.createElement('script');
script.src = DATA_URL + '?t=' + Date.now();
script.onload = function () { journeyData = global.MARKETING_JOURNEY || null; script.remove(); callback(); };
script.onerror = function () { console.error('KPIs tab: failed to load ' + DATA_URL); script.remove(); callback(); };
document.head.appendChild(script);
}
function render() {
var root = document.getElementById(ROOT_ID);
if (!root) return;
root.innerHTML = buildTopRow(journeyData) + buildJourneyBoard(journeyData) + buildTmsBlock(journeyData) +
'
Refreshed ' + new Date().toLocaleTimeString('en-AU', { hour: '2-digit', minute: '2-digit' }) + ' · auto-refresh every ' + (REFRESH_MS / 60000) + ' min
';
if (leadGenOpen) loadLeadGenFrame(); // card rebuilt with the toggle open; reload its iframe
observeWaitlist();
attachPopover(root);
}
function refresh() {
loadJourneyData(function () { refreshPageArrays(render); });
}
function toggleBox(id, storageKey) {
var box = document.getElementById(id);
if (!box) return false;
box.classList.toggle('collapsed');
var isOpen = !box.classList.contains('collapsed');
if (storageKey) { try { global.localStorage.setItem(storageKey, isOpen ? '0' : '1'); } catch (err) { /* storage unavailable */ } }
return isOpen;
}
function onRootClick(e) {
var header = e.target.closest && e.target.closest('[data-action]');
if (!header) return;
var action = header.getAttribute('data-action');
if (action === 'toggle-journey') toggleBox('kpis-journey-board', COLLAPSED_KEY);
else if (action === 'toggle-leadgen') {
leadGenOpen = toggleBox('kpis-leadgen', null);
if (leadGenOpen) loadLeadGenFrame();
}
}
function init() {
// Inside the Facebook Graphs iframe (same page framed by itself) only the Overview charts are
// wanted โ skip the KPIs tab there so it never nests another copy or polls the data file.
if (global.self !== global.top) return;
var root = document.getElementById(ROOT_ID);
if (!root) { console.error('KPIs tab: #' + ROOT_ID + ' missing'); return; }
var style = document.createElement('style');
style.textContent = CSS;
document.head.appendChild(style);
root.addEventListener('click', onRootClick);
render(); // paint immediately from the page's own arrays
loadJourneyData(render); // then fill the board once the data file lands
setInterval(refresh, REFRESH_MS);
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
global.KpisTab = { refresh: refresh, render: render };
})(window);