/* KPIs tab for meta-ads-lifetime-dashboard.html and meta-ads-lifetime-dashboard-team.html (shared script). * * Mirrors the Mona dashboard's (mona-dashboard.html) marketing boxes, ported โ€” not iframed โ€” * so the team page needs no second data pipeline and never loads the Mona page itself: * 1. Top row โ€” ๐Ÿ“˜ Facebook Data card (left: Ad Budget / Impressions / CTR / Results, last vs this * month, from this page's MONTHS; "Facebook Graphs" toggle = same-origin iframe of this * page's own LEAD GENERATION section) + Calendly Appts card (right: via FB aka RESULTS, * ALL, next available slot). * 2. Marketing Journey Board No# โ€” Conversion Ratio (+ est GP referrals from FB), GP Referrals * (est via FB + ALL month/week), Total Mapped patients + Current TMS numbers, * ๐Ÿ“‚ Waitlist Journey Board. Return-on-spend card, estimated-margin figures and the practice-API tile are * omitted on both pages (same script). * 3. ๐Ÿง  Current TMS Patients โ€” weekly bars + average line (tms_patients in the same data file). * * Dynamic: /marketing-journey-data.js (tools/marketing_journey_board.py, launchd) is reloaded every * REFRESH_MS, and the page's own MONTHS / CALENDLY_MONTHLY / GP_REFERRALS literals are re-fetched from * the served HTML so the cards track the launchd refresh fleet without a browser reload. * Hover/focus any dashed heading โ†’ popover chart (CreativeStatusExplainer, already on the page). */ (function (global) { 'use strict'; var ROOT_ID = 'kpis-tab-root'; var DATA_URL = '/marketing-journey-data.js'; var REFRESH_MS = 5 * 60 * 1000; var MONTHS_SHOWN = 12; var WAITLIST_STALE_DAYS = 7; var WAITLIST_MIN_SPAN = 2; // free grid slots needed before the waitlist tile sits beside the last tile var COLLAPSED_KEY = 'kpisJourneyCollapsed'; var LEADGEN_SECTION_ID = 'leadGenFbBody'; // LEAD GENERATION (Facebook Data) section on this page's Overview tab var LEADGEN_FIT_DELAYS_MS = [300, 1500]; // Chart.js needs a tick after display:none โ†’ block var COLORS = { blue: '#58a6ff', green: '#3fb950', amber: '#d29922', purple: '#a371f7', red: '#f85149', muted: 'var(--muted)' }; var CHART = { above: '#34d399', below: '#f87171', average: '#fbbf24', grid: 'rgba(139,148,158,0.18)' }; var TILE_STYLE = 'padding:12px; background:rgba(48,54,61,0.3); border-radius:8px; border-left:3px solid '; var HEADER_STYLE = 'display:flex; align-items:center; gap:6px; cursor:pointer; padding:4px 0; margin-bottom:8px; font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:0.05em; user-select:none;'; var DIVIDER_STYLE = 'border-top:1px solid var(--border); margin-top:8px; padding-top:6px;'; var CSS = '#' + ROOT_ID + ' .kp-card{background:var(--card);border:1px solid var(--border);border-radius:16px;padding:18px 20px;margin-bottom:20px;}' + '#' + ROOT_ID + ' .kp-hover{cursor:help;border-bottom:1px dashed var(--muted);outline:none;}' + '#' + ROOT_ID + ' .kp-hover:focus-visible{box-shadow:0 0 0 2px #818cf8;}' + '#' + ROOT_ID + ' .kp-tile-title{font-size:15px;font-weight:600;color:var(--text);margin-bottom:8px;}' + '#' + ROOT_ID + ' .kp-kpi{font-size:28px;font-weight:600;line-height:1.15;}' + '#' + ROOT_ID + ' .kp-kpi-label{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:2px;}' + '#' + ROOT_ID + ' .kp-sub{font-size:11px;color:var(--muted);margin-top:4px;}' + '#' + ROOT_ID + ' .kp-est{' + DIVIDER_STYLE + '}' + '#' + ROOT_ID + ' .kp-est .kp-kpi{font-size:18px;}' + '#' + ROOT_ID + ' .kp-row{display:flex;gap:16px;align-items:stretch;margin-bottom:20px;}' + '#' + ROOT_ID + ' .kp-row>.kp-card{margin-bottom:0;}' + '#' + ROOT_ID + ' .kp-fb-card{flex:0 0 calc(66.667% - 8px);max-width:calc(66.667% - 8px);min-width:0;}' + '#' + ROOT_ID + ' .kp-cal-card{flex:1 1 0;min-width:0;display:flex;}' + '#' + ROOT_ID + ' .kp-cal-card>div{width:100%;box-sizing:border-box;}' + '@media (max-width:900px){#' + ROOT_ID + ' .kp-row{flex-direction:column;}#' + ROOT_ID + ' .kp-fb-card{flex:1 1 auto;max-width:none;}}' + '#' + ROOT_ID + ' .fb-title{font-size:12px;font-weight:700;color:#4a9eff;margin-bottom:8px;text-transform:uppercase;letter-spacing:0.05em;text-align:center;}' + '#' + ROOT_ID + ' .fb-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;}' + '@media (max-width:1100px){#' + ROOT_ID + ' .fb-grid{grid-template-columns:repeat(2,1fr);}}' + '#' + ROOT_ID + ' .fb-col{min-width:0;}' + '#' + ROOT_ID + ' .fb-col+.fb-col{border-left:1px solid var(--border);padding-left:12px;}' + '#' + ROOT_ID + ' .fb-heading{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:4px;}' + '#' + ROOT_ID + ' .fb-label{font-size:9px;color:var(--muted);text-transform:uppercase;letter-spacing:0.04em;margin-top:6px;}' + '#' + ROOT_ID + ' .fb-value{font-size:20px;font-weight:700;color:#4a9eff;line-height:1.2;}' + '#' + ROOT_ID + ' .fb-sub{font-size:10px;color:var(--muted);}' + '#' + ROOT_ID + ' .fb-source{font-size:10px;color:var(--muted);margin-top:8px;text-align:center;}' + '#' + ROOT_ID + ' .kp-collapsible.collapsed .kp-body{display:none;}' + '#' + ROOT_ID + ' .kp-collapsible.collapsed .kp-toggle{transform:rotate(0deg);}' + '#' + ROOT_ID + ' .kp-toggle{display:inline-block;font-size:9px;transform:rotate(90deg);transition:transform .2s;}' + '#' + ROOT_ID + ' .kp-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px;}' + '#' + ROOT_ID + ' .kp-foot{font-size:10px;color:var(--muted);margin-top:6px;}' + '#' + ROOT_ID + ' .kp-leadgen{margin-top:10px;' + DIVIDER_STYLE + '}' + '#' + ROOT_ID + ' .kp-leadgen iframe{width:100%;height:1200px;border:none;border-radius:8px;display:block;background:transparent;}'; // โ”€โ”€ helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function esc(v) { return String(v == null ? '' : v).replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); } function fmtNum(v) { return Math.round(v).toLocaleString('en-AU'); } function compact(v) { var abs = Math.abs(v); if (abs < 10) return v.toFixed(2).replace(/\.?0+$/, ''); if (abs >= 1e6) return (v / 1e6).toFixed(1).replace(/\.0$/, '') + 'M'; if (abs >= 1e3) return (v / 1e3).toFixed(abs >= 1e4 ? 0 : 1).replace(/\.0$/, '') + 'k'; return String(Math.round(v)); } function isoDate(iso) { var d = new Date(String(iso || '').slice(0, 10) + 'T00:00:00'); return isNaN(d.getTime()) ? null : d; } function ddmmLabel(ddmmyyyy) { var p = String(ddmmyyyy).split('/'); if (p.length !== 3) return esc(ddmmyyyy); var d = new Date(Number(p[2]), Number(p[1]) - 1, Number(p[0])); return isNaN(d.getTime()) ? esc(ddmmyyyy) : d.toLocaleDateString('en-AU', { month: 'short', day: 'numeric' }); } function jobMeta(jobName, iso) { if (!iso) return jobName + ' ยท last run unknown'; var s = new Date(iso).toLocaleString('en-AU', { timeZone: 'Australia/Perth', day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit', hour12: false }); return jobName + ' ยท ' + s; } function average(values) { return values.length ? values.reduce(function (a, b) { return a + b; }, 0) / values.length : 0; } function hoverHeading(key, text, hint) { return '' + text + ''; } function value(valueHtml, color, subHtml, tooltip) { return '
' + valueHtml + '
' + (subHtml ? '
' + subHtml + '
' : ''); } function tile(title, color, innerHtml, extraStyle) { return '
' + '
' + title + '
' + innerHtml + '
'; } function half(label, valueHtml, withDivider) { return '
' + '
' + label + '
' + valueHtml + '
'; } function split(leftLabel, leftHtml, rightLabel, rightHtml) { return '
' + half(leftLabel, leftHtml, false) + half(rightLabel, rightHtml, true) + '
'; } function innerSection(titleHtml, bodyHtml) { return '
' + titleHtml + '
' + bodyHtml + '
'; } function unavailable(errors, section) { return value('—', COLORS.muted, 'unavailable', (errors && errors[section]) || 'no data'); } function legend(avgLabel) { var sw = function (colour, dashed) { return ''; }; return '
' + '' + sw(CHART.above) + 'Above average' + sw(CHART.below) + 'Below average' + '' + sw(CHART.average, true) + 'Average (' + esc(avgLabel) + ')
'; } // โ”€โ”€ charts (inline SVG, no Chart.js) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // rows: [{label, value}] โ†’ monthly bars (green โ‰ฅ avg, red < avg) + dashed average line function barChartSvg(rows, opts) { var c = { width: 640, height: 240, padLeft: 46, padRight: 12, padTop: 16, padBottom: 30, yTicks: 4 }; var values = rows.map(function (r) { return r.value; }); var avg = average(values); var hi = Math.max.apply(null, values.concat([avg])) * 1.12 || 1; var plotW = c.width - c.padLeft - c.padRight, plotH = c.height - c.padTop - c.padBottom; var yOf = function (v) { return c.padTop + plotH * (1 - v / hi); }; var slot = plotW / rows.length, barW = Math.max(6, slot * 0.6); var parts = []; for (var t = 0; t <= c.yTicks; t++) { var v = hi * t / c.yTicks, y = yOf(v); parts.push(''); parts.push('' + compact(v) + ''); } rows.forEach(function (r, i) { var x = c.padLeft + slot * i + (slot - barW) / 2, y = yOf(r.value); var colour = r.value >= avg ? CHART.above : CHART.below; parts.push('' + esc(r.label) + ': ' + esc(opts.fmt(r.value)) + ' ' + esc(opts.unit) + ''); parts.push('' + esc((opts.short || opts.fmt)(r.value)) + ''); parts.push('' + esc(r.label) + ''); }); var yAvg = yOf(avg).toFixed(1); parts.push('Average ' + esc(opts.fmt(avg)) + ''); return '' + parts.join('') + '' + legend(opts.fmt(avg)); } // rows: [{date: dd/mm/yyyy, total}] โ†’ weekly bars with a fixed average line (TMS / GP / mapping) function weeklyChartSvg(rows, avg, opts) { var c = { width: 960, height: 230, padLeft: 44, padRight: 12, padTop: 14, padBottom: 34, maxXLabels: 13, yTicks: 4 }; var unit = (opts && opts.unit) || 'patients'; var totals = rows.map(function (r) { return r.total; }); var lo = Math.max(0, Math.floor((Math.min.apply(null, totals.concat([avg])) - 5) / 10) * 10); var hi = Math.ceil((Math.max.apply(null, totals.concat([avg])) + 5) / 10) * 10; if (hi <= lo) hi = lo + 10; var plotW = c.width - c.padLeft - c.padRight, plotH = c.height - c.padTop - c.padBottom; var yOf = function (v) { return c.padTop + plotH * (1 - (v - lo) / (hi - lo)); }; var slot = plotW / rows.length, barW = Math.max(2, slot * 0.7); var labelEvery = Math.max(1, Math.ceil(rows.length / c.maxXLabels)); var parts = []; for (var t = 0; t <= c.yTicks; t++) { var v = lo + (hi - lo) * t / c.yTicks, y = yOf(v); parts.push(''); parts.push('' + Math.round(v) + ''); } rows.forEach(function (r, i) { var x = c.padLeft + slot * i + (slot - barW) / 2, y = yOf(r.total); var colour = r.total >= avg ? CHART.above : CHART.below; parts.push('' + esc(r.date) + ': ' + esc(r.total) + ' ' + esc(unit) + ''); if (i % labelEvery === 0 || i === rows.length - 1) { var lx = (x + barW / 2).toFixed(1), ly = c.height - c.padBottom + 14; parts.push('' + ddmmLabel(r.date) + ''); } }); var yAvg = yOf(avg).toFixed(1); parts.push('Average ' + Math.round(avg) + ''); return '' + parts.join('') + ''; } function weeklyVerdict(rows, avg, noun) { var latest = rows[rows.length - 1]; var isHigh = latest.total >= avg; return '
Latest week (' + esc(latest.date) + '): ' + esc(latest.total) + ' ' + noun + ' vs average ' + Math.round(avg) + ' (' + (isHigh ? 'above' : 'below') + ' average) · ' + rows.length + ' weeks
'; } // โ”€โ”€ page-embedded arrays (MONTHS / CALENDLY_MONTHLY / GP_REFERRALS) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Read the page's own literals on first paint, then re-fetch the served HTML on each // refresh so a regenerated dashboard shows up without a manual reload. function pageArray(name) { try { return new Function('return typeof ' + name + ' !== "undefined" ? ' + name + ' : null')(); } catch (e) { return null; } } var pageData = { MONTHS: pageArray('MONTHS'), CALENDLY_MONTHLY: pageArray('CALENDLY_MONTHLY'), GP_REFERRALS: pageArray('GP_REFERRALS') }; function extractLiteral(src, name) { var m = src.match(new RegExp('const ' + name + ' = (\\[[\\s\\S]*?\\n\\]);')); if (!m) throw new Error(name + ' not found in served page'); return new Function('return ' + m[1])(); // own-origin generated literal, not user input } function refreshPageArrays(callback) { fetch(global.location.pathname + '?t=' + Date.now()).then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status + ' re-fetching dashboard'); return r.text(); }).then(function (src) { var next = {}; Object.keys(pageData).forEach(function (name) { try { next[name] = extractLiteral(src, name); } catch (e) { console.error('KPIs tab: ' + e.message); next[name] = pageData[name]; } }); pageData = next; callback(); }).catch(function (err) { console.error('KPIs tab: page re-fetch failed', err); callback(); }); } // โ”€โ”€ ๐Ÿ“˜ Facebook Data card โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ var FB_METRICS = { fb_spend: { key: 'spend', heading: 'Facebook Data', unit: 'spend (AUD)', title: '📘 Facebook Ad Budget — last 12 months', hint: 'Hover for the last 12 months of Facebook ad budget (spend)', fmt: function (v) { return '$' + fmtNum(v); }, short: function (v) { return '$' + compact(v); } }, fb_impressions: { key: 'impressions', heading: 'Impressions', unit: 'impressions', title: '👁 Facebook Impressions — last 12 months', hint: 'Hover for the last 12 months of Facebook impressions', fmt: fmtNum, short: compact }, fb_ctr: { key: 'ctr_link', heading: 'CTR', unit: 'CTR (link)', title: '🔗 Facebook CTR (link) — last 12 months', hint: 'Hover for the last 12 months of Facebook link click-through rate', fmt: function (v) { return Number(v).toFixed(2) + '%'; } }, fb_results: { key: 'results', heading: 'Results', unit: 'results', title: '🎯 Facebook Results — last 12 months', hint: 'Hover for the last 12 months of Facebook results (leads)', fmt: fmtNum } }; function fbRows(metric) { return (pageData.MONTHS || []).slice(-MONTHS_SHOWN).map(function (m) { return { label: m.short || m.month, value: Number(m[metric.key]) || 0 }; }); } // {cur, prev} rows for one metric (prev = last completed month, cur = this month to date) function fbLastTwo(metric) { var rows = fbRows(metric); return { cur: rows[rows.length - 1] || null, prev: rows.length > 1 ? rows[rows.length - 2] : null }; } function fbColumn(mirrorKey) { var metric = FB_METRICS[mirrorKey]; var lt = fbLastTwo(metric); var cell = function (label, row, suffix) { return '
' + label + '
' + '
' + (row ? esc(metric.fmt(row.value)) : '—') + '
' + '
' + (row ? esc(row.label) + suffix : 'no prior month') + '
'; }; return '
' + hoverHeading(mirrorKey, metric.heading, metric.hint) + '
' + cell('Last month', lt.prev, '') + cell('This month', lt.cur, ' ยท to date') + '
'; } // "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 '
' + '
► Facebook Graphs
' + '
โฌ‡๏ธFlow Chart View tab → LEAD GENERATION (Facebook Data) · 5 graphs
' + '
'; } // 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)
'; } else { inner = '
' + Object.keys(FB_METRICS).map(fbColumn).join('') + '
' + '
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 '
' + tile(CALENDLY_FB_HEADING, COLORS.blue, body, 'height:100%; box-sizing:border-box;') + '
'; } function buildTopRow(data) { return '
' + buildFbCard() + buildCalendlyCard(data) + '
'; } // โ”€โ”€ 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 '
' + '
' + s[0] + '
' + val + '
'; }).join(''); body = '
' + cells + '
'; } return '
' + tile(WAITLIST_HEADING, COLORS.blue, body, 'height:100%; box-sizing:border-box;') + '
'; } // 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 = '
' + conversionTile(data, errors) + gpTile(data, errors) + mappedTmsTile(data, errors) + waitlistTile(data, errors) + '
' + '
' + esc(jobMeta('marketing-journey-board', data.generated_at)) + (Object.keys(errors).length ? ' · ' + Object.keys(errors).length + ' section(s) failed' : '') + '
'; } var collapsed = false; try { collapsed = global.localStorage.getItem(COLLAPSED_KEY) === '1'; } catch (e) { /* storage unavailable */ } return '
' + '
► Marketing Journey Board No#
' + '
' + 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 '
' + tile(title, COLORS.green, unavailable(data && data.errors, 'tms_patients')) + '
'; } var latest = t.latest || t.rows[t.rows.length - 1]; var isHigh = latest.total >= t.average; var colour = isHigh ? CHART.above : CHART.below; var subtitle = '
Weekly patient count from Neuralia TMS Action Log · last ' + esc(t.window_months) + ' months. Green bars = above average, red = below.
'; var verdict = '
Latest week (' + esc(latest.date) + '): ' + esc(latest.total) + ' patients vs average ' + Math.round(t.average) + ' (' + (isHigh ? 'above' : 'below') + ' average)
'; return '
' + tile(title, colour, subtitle + weeklyChartSvg(t.rows, t.average, { unit: 'patients', label: 'Weekly current TMS patients with average line' }) + legend(Math.round(t.average)) + verdict, 'min-width:0; box-sizing:border-box;') + '
'; } // โ”€โ”€ hover popovers (local data only โ€” no cross-page fetch) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function waitlistAsOf(row) { if (row.source === 'live') return { text: 'live Mon', stale: false, hint: 'Captured live on the Monday (' + String(row.as_of || '').replace('T', ' ').slice(0, 16) + ')' }; if (row.source !== 'revision' || !row.as_of) return { text: '—', stale: true, hint: 'No snapshot or revision available for this week' }; var monday = isoDate(row.monday), asOf = isoDate(row.as_of); var gapDays = (monday && asOf) ? Math.round((monday - asOf) / 86400000) : null; var stale = gapDays == null || gapDays > WAITLIST_STALE_DAYS; return { text: asOf ? asOf.toLocaleDateString('en-AU', { day: 'numeric', month: 'short' }) : esc(row.as_of), stale: stale, hint: 'Google Drive revision saved ' + String(row.as_of).replace('T', ' ').slice(0, 16) + (gapDays != null ? ' (' + gapDays + ' day' + (gapDays === 1 ? '' : 's') + ' before this Monday)' : '') }; } function waitlistHistoryTable(rows) { var th = function (label, align) { return '' + label + ''; }; var head = '' + th('Week (Mon)', 'left') + WAITLIST_COLUMNS.map(function (c) { return th(c[0], 'right'); }).join('') + th('As of', 'right') + ''; 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 '' + (v == null ? '—' : esc(v)) + ''; }).join(''); return '' + '' + (monday ? monday.toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' }) : esc(r.monday)) + '' + cells + '' + asOf.text + ''; }).join(''); return '
' + head + '' + body + '
' + '
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 breakdown unavailable (' + esc(errors.calendly_clinics || 'no calendly_clinics in marketing-journey-data.js') + ')

'; } var th = function (label, align) { return '' + label + ''; }; var td = function (v, opts) { opts = opts || {}; var colour = opts.colour || (v ? COLORS.blue : 'var(--muted)'); return '' + (v ? esc(v) : '·') + ''; }; var head = '' + th('Month', 'left') + c.clinics.map(function (name) { return th(esc(name), 'right'); }).join('') + th('Total', 'right') + ''; var body = c.rows.map(function (r) { var cells = c.clinics.map(function (name) { return td(r.counts[name] || 0, name === 'Unknown' ? { colour: 'var(--muted)' } : {}); }).join(''); var label = esc(r.label) + (r.is_current_month ? ' (MTD+booked)' : ''); return '' + label + '' + cells + td(r.total, { bold: true, colour: 'var(--text)' }) + ''; }).join(''); var foot = 'All months' + c.clinics.map(function (name) { var n = c.totals[name] || 0, pct = c.total ? Math.round(100 * n / c.total) : 0; return '' + esc(n) + ' ' + pct + '%'; }).join('') + td(c.total, { bold: true, colour: 'var(--text)' }) + ''; return title + '
' + head + '' + body + '' + foot + '
' + '
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) + '
' + (prev ? '' + esc(prev.label) + ' (last month): ' + esc(metric.fmt(prev.value)) + ' · ' : '') + '' + esc(cur.label) + ' (this month, to date): ' + esc(metric.fmt(cur.value)) + '
'; } }; }); function popoverHtml(key) { var spec = MIRRORS[key]; var head = '

' + spec.title + '

'; try { return head + spec.html(journeyData || {}) + '
' + spec.provenance + '
'; } catch (e) { return head + '

Chart unavailable (' + esc(e.message) + ')

'; } } 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);