Diff -- /var/www/vhosts/3dshawn.com/abforge.3dshawn.com/TEMPLATES/abforge_dashboard.html
Diff
/var/www/vhosts/3dshawn.com/abforge.3dshawn.com/TEMPLATES/abforge_dashboard.html
added on local at 2026-07-01 16:01:48
Added
+197
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 2e29877d123b
to 2e29877d123b
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.| 1 | [if:!$has_sites] | |
| 2 | <div class="card" style="text-align:center; padding: 60px 24px;"> | |
| 3 | <h2 style="font-family:'Space Grotesk',sans-serif; font-size:24px; margin:0 0 6px;">Welcome to ABForge.</h2> | |
| 4 | <p class="muted" style="margin:0 0 22px;">Add your first website to start tracking visitors, running experiments and watching heatmaps light up.</p> | |
| 5 | <a class="btn btn-brand btn-lg" href="/sites.cgi?new=1">+ Add my first site</a> | |
| 6 | </div> | |
| 7 | [/if] | |
| 8 | ||
| 9 | [if:$has_sites] | |
| 10 | <div class="page-header"> | |
| 11 | <div> | |
| 12 | <h1>$site_name</h1> | |
| 13 | <p>$domain · <span style="color: var(--good);">● $live live now</span></p> | |
| 14 | </div> | |
| 15 | <div class="flex gap-8"> | |
| 16 | <a class="btn btn-ghost btn-sm" href="/sites.cgi?id=$site_id">Site settings</a> | |
| 17 | <a class="btn btn-secondary btn-sm" href="/realtime.cgi?site_id=$site_id">Real-time →</a> | |
| 18 | </div> | |
| 19 | </div> | |
| 20 | ||
| 21 | <div class="kpi-grid"> | |
| 22 | <div class="kpi" data-kpi-detail='{"description":"Total pageviews recorded for <strong>$site_name</strong> over the last 30 days. Every page load (including reloads and navigations within the same session) counts as one pageview.","methodology":"Each time <code>/t.js</code> fires on a page in your tracking domain, it sends a beacon to <code>/c.cgi</code>. The pageviews counter is <code>COUNT(*)</code> over the <code>pageviews</code> table for this site, filtered to the trailing 30 days.","interpretation":"Pageviews answer <em>how much traffic is the site getting</em>. To understand <em>quality</em> of that traffic, pair this with Bounce Rate (are people engaging?) and Pages/Session (are they exploring?). A pageview-heavy + low-bounce site is healthy; pageview-heavy + high-bounce often means a single landing page is doing all the work.","insights":[{"label":"Right now","value":"$pv pageviews from $visitors unique visitors across $sessions sessions.","tone":"info"},{"label":"Per visitor","value":"On average each visitor saw multiple pages — see Pages/Session for the ratio.","tone":"info"}],"drilldowns":[{"label":"Top pages","href":"/pages.cgi?site_id=$site_id"},{"label":"Traffic sources","href":"/sources.cgi?site_id=$site_id"},{"label":"Real-time","href":"/realtime.cgi?site_id=$site_id"}],"note":"Counts every load — reloads and back-button navigations included."}'> | |
| 23 | <div class="kpi-label">Pageviews / 30d</div><div class="kpi-value">$pv</div> | |
| 24 | </div> | |
| 25 | ||
| 26 | <div class="kpi" data-kpi-detail='{"description":"Distinct people who visited <strong>$site_name</strong> in the last 30 days, identified by an anonymous <code>client_id</code> cookie set by <code>/t.js</code>. One visitor can have many sessions over the period; they’re only counted once here.","methodology":"<code>COUNT(DISTINCT client_id)</code> over the <code>pageviews</code> table for this site, filtered to the trailing 30 days. The client_id is a UUID stored in a 1st-party cookie with a 2-year TTL — if a visitor clears cookies or switches browsers, they’ll be counted as new.","interpretation":"This is the closest thing to <em>reach</em> we can measure without identifying users by email. Compare against your acquisition spend to estimate cost-per-visitor; compare against signups (in the funnel report) for visitor-to-customer conversion.","insights":[{"label":"Right now","value":"$visitors unique visitors generated $sessions sessions — each visitor averages about $pv_per pages per visit.","tone":"info"},{"label":"Cookie-based","value":"Visitors who block cookies or use private browsing get re-counted on each visit. The true number is typically 5–15% lower.","tone":"warn"}],"drilldowns":[{"label":"Geography","href":"/geo.cgi?site_id=$site_id"},{"label":"Devices","href":"/devices.cgi?site_id=$site_id"},{"label":"Sources","href":"/sources.cgi?site_id=$site_id"}],"note":"Cookie-based — cookie clears or browser changes count as new."}'> | |
| 27 | <div class="kpi-label">Unique visitors</div><div class="kpi-value">$visitors</div> | |
| 28 | </div> | |
| 29 | ||
| 30 | <div class="kpi" data-kpi-detail='{"description":"A <em>session</em> is one continuous browsing visit. A new session starts when the same visitor returns after <strong>30 minutes of inactivity</strong>, or when they come back the next day. One visitor with two coffee-break gaps in their browsing = 3 sessions.","methodology":"On every <code>/c.cgi</code> beacon, the tracker checks <code>last_seen_at</code> for the client_id. If the gap is > 30 minutes (or the date rolled over), a new <code>session_id</code> is minted. Sessions counter = <code>COUNT(DISTINCT session_id)</code> for the site, trailing 30 days.","interpretation":"Sessions are the unit most analytics tools agree on. Use them as the denominator for conversion rate (signups / sessions), bounce rate, and engagement metrics. If sessions are flat but pageviews are up, your existing visitors are exploring more — a good sign.","insights":[{"label":"Sessions per visitor","value":"$visitors visitors produced $sessions sessions — average visitor came back multiple times in the window.","tone":"good"},{"label":"30-min rule","value":"The 30-minute inactivity window is the industry standard. A user who reads one page for an hour then clicks a link starts a new session.","tone":"info"}],"drilldowns":[{"label":"Real-time","href":"/realtime.cgi?site_id=$site_id"},{"label":"Funnels","href":"/funnels.cgi?site_id=$site_id"}],"note":"30-minute inactivity window opens a new session."}'> | |
| 31 | <div class="kpi-label">Sessions</div><div class="kpi-value">$sessions</div> | |
| 32 | </div> | |
| 33 | ||
| 34 | <div class="kpi" data-kpi-detail='{"description":"Percentage of sessions in which the visitor saw exactly <strong>one page</strong> and left without further interaction. <strong>Lower is generally better</strong>, but context matters: blog posts and landing pages naturally have higher bounce because the visitor got what they came for on page one.","methodology":"<code>(sessions with exactly 1 pageview) / (total sessions) × 100</code>, computed over the trailing 30 days for this site.","interpretation":"For a SaaS dashboard or app, bounce should be < 40% — visitors come to <em>do</em> something across multiple pages. For a blog or article-driven site, 60–80% is normal. If your bounce spiked recently, check Top Pages for a high-traffic page that’s collecting bounces.","insights":[{"label":"Current","value":"$bounce% of your $sessions sessions ended on the first page.","tone":"info"},{"label":"Benchmark","value":"Industry averages: SaaS 30–50% · E-commerce 20–40% · Blogs 60–80% · Landing pages 70–90%.","tone":"info"},{"label":"Tip","value":"Bounce is per-session, not per-visitor. A visitor who bounces once and converts later still counts as one bounce.","tone":"info"}],"drilldowns":[{"label":"Top pages","href":"/pages.cgi?site_id=$site_id"},{"label":"Sources","href":"/sources.cgi?site_id=$site_id"}],"note":"Single-pageview sessions only. Lower is usually better."}'> | |
| 35 | <div class="kpi-label">Bounce rate</div><div class="kpi-value">$bounce%</div> | |
| 36 | </div> | |
| 37 | ||
| 38 | <div class="kpi" data-kpi-detail='{"description":"Average length of a session, measured as the gap between the <strong>first</strong> and <strong>last</strong> event the visitor sent during that session. Each session contributes its own duration; the value shown is the mean across all sessions.","methodology":"For each session, time-on-site = <code>MAX(event_time) - MIN(event_time)</code>. The dashboard averages this across all sessions in the 30-day window. <strong>Single-pageview sessions have no measurable duration</strong> (only one event = no gap), so they’re counted as 0 seconds — which is why a high bounce rate drags this number down hard.","interpretation":"A value of <strong>0s</strong> almost always means most of your sessions are single-pageview bounces. To get a real Avg Time, you need multi-page sessions or instrumented events (scroll depth, button clicks) within a page. Once you have those, anything above 30s for a content site or 2 minutes for an app is healthy.","insights":[{"label":"Right now","value":"Current value: $avg_time. With $sessions sessions and a $bounce% bounce rate, single-pv sessions are pulling this average toward 0.","tone":"warn"},{"label":"Fix","value":"Add explicit events (button clicks, scroll-depth markers) on key pages — they give sessions multiple events even when the user stays on one URL.","tone":"info"}],"drilldowns":[{"label":"Goals","href":"/goals.cgi?site_id=$site_id"},{"label":"Funnels","href":"/funnels.cgi?site_id=$site_id"}],"note":"Single-pageview sessions count as 0s — high bounce drags this down."}'> | |
| 39 | <div class="kpi-label">Avg time on site</div><div class="kpi-value">$avg_time</div> | |
| 40 | </div> | |
| 41 | ||
| 42 | <div class="kpi" data-kpi-detail='{"description":"Average pageviews per session. Tells you <strong>how deep into the site</strong> a typical visitor browses before leaving. 1.0 = everyone visits exactly one page; 3.0 = the average visitor explores 3 pages.","methodology":"<code>total pageviews / total sessions</code> over the trailing 30 days. Bounce sessions (1 pv) pull this number toward 1.0; deep-exploration sessions pull it up.","interpretation":"This is the inverse signal of Bounce Rate — they’ll usually move together (high bounce → low pages/session). For content sites, target 2.0+; for app dashboards, 4–10+ is normal because the user navigates between many internal views.","insights":[{"label":"Current","value":"$pv_per pages per session over $sessions sessions.","tone":"info"},{"label":"Benchmark","value":"Content sites: aim for 2.0+. SaaS apps: 4–10. Single-purpose landing pages: 1.0–1.3 is expected.","tone":"info"},{"label":"Move it up","value":"Add clear next-action CTAs on each page. Use internal links in body copy. Surface related content at the bottom of articles.","tone":"good"}],"drilldowns":[{"label":"Top pages","href":"/pages.cgi?site_id=$site_id"},{"label":"Funnels","href":"/funnels.cgi?site_id=$site_id"}],"note":"Inverse of bounce — these two metrics move together."}'> | |
| 43 | <div class="kpi-label">Pages / session</div><div class="kpi-value">$pv_per</div> | |
| 44 | </div> | |
| 45 | </div> | |
| 46 | ||
| 47 | <div class="card"> | |
| 48 | <div class="card-header"> | |
| 49 | <h3 class="card-title">Last 30 days — pageviews & visitors</h3> | |
| 50 | </div> | |
| 51 | <div class="chart-wrap" style="height:240px;"> | |
| 52 | <svg id="dash-chart" class="chart-canvas" viewBox="0 0 1000 240" preserveAspectRatio="none"></svg> | |
| 53 | </div> | |
| 54 | </div> | |
| 55 | ||
| 56 | <div class="row-2"> | |
| 57 | <div class="card"> | |
| 58 | <div class="card-header"><h3 class="card-title">Top pages</h3><a href="/pages.cgi?site_id=$site_id" class="text-sm muted">See all →</a></div> | |
| 59 | <table class="table"> | |
| 60 | <thead><tr><th>Page</th><th class="right">Pageviews</th><th class="right">Avg time</th></tr></thead> | |
| 61 | <tbody> | |
| 62 | [loop:@pages] | |
| 63 | <tr><td style="max-width:380px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">$page_path</td><td class="num">$pageviews</td><td class="num">$avg_time<span class="muted text-sm">s</span></td></tr> | |
| 64 | [/loop] | |
| 65 | </tbody> | |
| 66 | </table> | |
| 67 | </div> | |
| 68 | ||
| 69 | <div class="card"> | |
| 70 | <div class="card-header"><h3 class="card-title">Traffic sources</h3><a href="/sources.cgi?site_id=$site_id" class="text-sm muted">See all →</a></div> | |
| 71 | <table class="table"> | |
| 72 | <thead><tr><th>Source</th><th class="right">Sessions</th></tr></thead> | |
| 73 | <tbody> | |
| 74 | [loop:@sources] | |
| 75 | <tr><td>$referrer_kind</td><td class="num">$sessions</td></tr> | |
| 76 | [/loop] | |
| 77 | </tbody> | |
| 78 | </table> | |
| 79 | </div> | |
| 80 | </div> | |
| 81 | ||
| 82 | <div class="row-2"> | |
| 83 | <div class="card"> | |
| 84 | <div class="card-header"><h3 class="card-title">Top countries</h3><a href="/geo.cgi?site_id=$site_id" class="text-sm muted">See map →</a></div> | |
| 85 | <table class="table"> | |
| 86 | <thead><tr><th>Country</th><th class="right">Visitors</th><th class="right">Sessions</th></tr></thead> | |
| 87 | <tbody> | |
| 88 | [loop:@countries] | |
| 89 | <tr><td>$country</td><td class="num">$visitors</td><td class="num">$sessions</td></tr> | |
| 90 | [/loop] | |
| 91 | </tbody> | |
| 92 | </table> | |
| 93 | </div> | |
| 94 | ||
| 95 | <div class="card"> | |
| 96 | <div class="card-header"><h3 class="card-title">Devices</h3><a href="/devices.cgi?site_id=$site_id" class="text-sm muted">See all →</a></div> | |
| 97 | <table class="table"> | |
| 98 | <thead><tr><th>Device</th><th class="right">Sessions</th><th class="right">Visitors</th></tr></thead> | |
| 99 | <tbody> | |
| 100 | [loop:@devices] | |
| 101 | <tr><td>$device_type</td><td class="num">$sessions</td><td class="num">$visitors</td></tr> | |
| 102 | [/loop] | |
| 103 | </tbody> | |
| 104 | </table> | |
| 105 | </div> | |
| 106 | </div> | |
| 107 | ||
| 108 | <script> | |
| 109 | (function() { | |
| 110 | var labels = $series_labels; | |
| 111 | var pv = $series_pv; | |
| 112 | var vs = $series_vs; | |
| 113 | var svg = document.getElementById('dash-chart'); | |
| 114 | if (!svg || !pv.length) return; | |
| 115 | var W = 1000, H = 240, P = 24; | |
| 116 | var max = Math.max.apply(null, pv.concat(vs, [1])); | |
| 117 | function pt(arr, i) { | |
| 118 | var x = P + (i / (arr.length - 1 || 1)) * (W - 2*P); | |
| 119 | var y = H - P - (arr[i] / max) * (H - 2*P); | |
| 120 | return x + ',' + y; | |
| 121 | } | |
| 122 | function polyline(arr) { | |
| 123 | return Array.from({length: arr.length}, function(_, i) { return pt(arr, i); }).join(' '); | |
| 124 | } | |
| 125 | function area(arr) { | |
| 126 | var pts = polyline(arr).split(' '); | |
| 127 | return pts.join(' ') + ' ' + (W-P) + ',' + (H-P) + ' ' + P + ',' + (H-P) + ' Z'; | |
| 128 | } | |
| 129 | var gridY = ''; | |
| 130 | for (var i = 0; i < 5; i++) { | |
| 131 | var y = P + i * (H - 2*P) / 4; | |
| 132 | gridY += '<line x1="'+P+'" y1="'+y+'" x2="'+(W-P)+'" y2="'+y+'" stroke="#20273b" stroke-width="1"/>'; | |
| 133 | } | |
| 134 | var html = gridY + | |
| 135 | '<defs>' + | |
| 136 | '<linearGradient id="gp" x1="0" x2="0" y1="0" y2="1">' + | |
| 137 | '<stop offset="0%" stop-color="#ff7a3d" stop-opacity="0.45"/>' + | |
| 138 | '<stop offset="100%" stop-color="#ff7a3d" stop-opacity="0"/></linearGradient>' + | |
| 139 | '<linearGradient id="gv" x1="0" x2="0" y1="0" y2="1">' + | |
| 140 | '<stop offset="0%" stop-color="#5aa9ff" stop-opacity="0.32"/>' + | |
| 141 | '<stop offset="100%" stop-color="#5aa9ff" stop-opacity="0"/></linearGradient>' + | |
| 142 | '</defs>' + | |
| 143 | '<path d="M ' + area(pv) + '" fill="url(#gp)" stroke="none"/>' + | |
| 144 | '<polyline points="' + polyline(pv) + '" fill="none" stroke="#ff7a3d" stroke-width="2"/>' + | |
| 145 | '<path d="M ' + area(vs) + '" fill="url(#gv)" stroke="none"/>' + | |
| 146 | '<polyline points="' + polyline(vs) + '" fill="none" stroke="#5aa9ff" stroke-width="2"/>' + | |
| 147 | '<line id="dash-cross" x1="0" y1="'+P+'" x2="0" y2="'+(H-P)+'" stroke="rgba(255,255,255,0.35)" stroke-width="1" stroke-dasharray="3 3" style="display:none"/>' + | |
| 148 | '<circle id="dash-dot-pv" r="4" fill="#ff7a3d" stroke="#fff" stroke-width="1.5" style="display:none"/>' + | |
| 149 | '<circle id="dash-dot-vs" r="4" fill="#5aa9ff" stroke="#fff" stroke-width="1.5" style="display:none"/>'; | |
| 150 | svg.innerHTML = html; | |
| 151 | ||
| 152 | // Hover crosshair + tooltip wired through window.ABForgeGraphs (loaded | |
| 153 | // by the shared wrapper). Falls back gracefully if graphs.js is absent. | |
| 154 | var cross = document.getElementById('dash-cross'); | |
| 155 | var dotPv = document.getElementById('dash-dot-pv'); | |
| 156 | var dotVs = document.getElementById('dash-dot-vs'); | |
| 157 | function fmtDate(s) { | |
| 158 | var m = String(s||'').match(/^(\d{4})-(\d{2})-(\d{2})$/); | |
| 159 | if (!m) return s || ''; | |
| 160 | var mn = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; | |
| 161 | return mn[parseInt(m[2],10)-1] + ' ' + parseInt(m[3],10) + ', ' + m[1]; | |
| 162 | } | |
| 163 | svg.addEventListener('mousemove', function(e) { | |
| 164 | var rect = svg.getBoundingClientRect(); | |
| 165 | var xpct = (e.clientX - rect.left) / rect.width; | |
| 166 | var i = Math.round(xpct * (pv.length - 1)); | |
| 167 | if (i < 0) i = 0; if (i > pv.length - 1) i = pv.length - 1; | |
| 168 | var xc = P + (i / (pv.length - 1 || 1)) * (W - 2*P); | |
| 169 | var yp = H - P - (pv[i] / max) * (H - 2*P); | |
| 170 | var yv = H - P - (vs[i] / max) * (H - 2*P); | |
| 171 | cross.setAttribute('x1', xc); cross.setAttribute('x2', xc); cross.style.display = ''; | |
| 172 | dotPv.setAttribute('cx', xc); dotPv.setAttribute('cy', yp); dotPv.style.display = ''; | |
| 173 | dotVs.setAttribute('cx', xc); dotVs.setAttribute('cy', yv); dotVs.style.display = ''; | |
| 174 | if (window.ABForgeGraphs && window.ABForgeGraphs.showTip) { | |
| 175 | var pct = pv[i] && pv.reduce(function(s,v){return s+v;},0) ? (pv[i] / pv.reduce(function(s,v){return s+v;},0) * 100) : 0; | |
| 176 | window.ABForgeGraphs.showTip( | |
| 177 | '<div class="wg-tip-eyebrow">Last 30 days · ' + fmtDate(labels[i]) + '</div>' + | |
| 178 | '<div class="wg-tip-main">' + pv[i].toLocaleString() + '</div>' + | |
| 179 | '<div class="wg-tip-sub">' + pct.toFixed(1) + '% of period pageviews</div>' + | |
| 180 | '<div class="wg-tip-divider"></div>' + | |
| 181 | '<div class="wg-tip-row"><span style="display:inline-flex;align-items:center;gap:6px"><span style="width:8px;height:8px;border-radius:2px;background:#ff7a3d"></span>Pageviews</span><span class="wg-tip-val">' + pv[i].toLocaleString() + '</span></div>' + | |
| 182 | '<div class="wg-tip-row"><span style="display:inline-flex;align-items:center;gap:6px"><span style="width:8px;height:8px;border-radius:2px;background:#5aa9ff"></span>Visitors</span><span class="wg-tip-val">' + vs[i].toLocaleString() + '</span></div>' + | |
| 183 | '<div class="wg-tip-row"><span>Pages / visitor</span><span class="wg-tip-val">' + (vs[i] ? (pv[i]/vs[i]).toFixed(2) : '0') + '</span></div>' + | |
| 184 | '<div class="wg-tip-row"><span>Position</span><span class="wg-tip-val">Day ' + (i+1) + ' of ' + pv.length + '</span></div>' | |
| 185 | ); | |
| 186 | } | |
| 187 | }); | |
| 188 | svg.addEventListener('mouseleave', function() { | |
| 189 | cross.style.display = 'none'; | |
| 190 | dotPv.style.display = 'none'; | |
| 191 | dotVs.style.display = 'none'; | |
| 192 | if (window.ABForgeGraphs && window.ABForgeGraphs.hideTip) window.ABForgeGraphs.hideTip(); | |
| 193 | }); | |
| 194 | svg.style.cursor = 'crosshair'; | |
| 195 | })(); | |
| 196 | </script> | |
| 197 | [/if] |