feat: unique visitors, bot filtering, and account-wide audience analytics

- Per-link analytics gain unique-visitor counts (period total and per-day),
  a bot_clicks figure, and an exclude_bots=1 filter applied across every
  breakdown, the daily series, and the heatmap. Bot classification comes
  from the is_bot flag stamped at click time.
- New GET /api/analytics/aggregate: one chart across the whole account (or
  org pool), optionally scoped to a single tag — daily clicks + uniques,
  referrers, devices, browsers, countries, and top links, capped to the
  plan's analytics window.
- Dashboard: Audience panel with tag/window/bot controls wired into
  loadDashboard, plus a NO BOTS toggle and PERIOD/UNIQUE/BOTS stat line on
  each link's analytics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jason Stedwell
2026-07-03 01:40:14 -05:00
parent 8f2bfd23d2
commit a321ff6171
2 changed files with 240 additions and 13 deletions
+111 -1
View File
@@ -635,6 +635,27 @@ input[type="range"]::-webkit-slider-thumb { -webkit-appearance:none; width:16px;
<canvas class="dash-chart-canvas" id="dash-chart" height="60"></canvas>
</div>
<!-- Audience analytics — aggregate across the account/org, filterable by tag -->
<div class="panel" id="audience-panel" style="margin-bottom:20px;display:none">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px;margin-bottom:14px">
<h3 style="margin:0">AUDIENCE — ACCOUNT ANALYTICS</h3>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<select class="field-input" id="agg-tag" style="padding:5px 8px;font-size:12px;width:auto" onchange="loadAudience()">
<option value="">All links</option>
</select>
<select class="field-input" id="agg-days" style="padding:5px 8px;font-size:12px;width:auto" onchange="loadAudience()">
<option value="7">7d</option>
<option value="30" selected>30d</option>
<option value="90">90d</option>
</select>
<label style="display:flex;align-items:center;gap:5px;font-size:11px;font-family:var(--font-mono);color:var(--muted);cursor:pointer">
<input type="checkbox" id="agg-bots" onchange="loadAudience()"> exclude bots
</label>
</div>
</div>
<div id="agg-content"><div style="color:var(--muted);font-size:12px;font-family:var(--font-mono)">Loading…</div></div>
</div>
<div class="toolbar">
<div class="search-wrap">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.5"/><path d="M11 11L14.5 14.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
@@ -1233,6 +1254,83 @@ function renderUptimeChart(days) {
async function loadDashboard() {
await Promise.all([loadStats(), loadTags(), loadPlanUsage()]);
await loadLinks();
loadAudience();
loadApiKeys();
}
// ── Audience — aggregate analytics across the account/org ──
async function loadAudience() {
const panel = document.getElementById('audience-panel');
panel.style.display = '';
// Keep the tag dropdown in sync with the user's tags
const tagSel = document.getElementById('agg-tag');
const current = tagSel.value;
tagSel.innerHTML = '<option value="">All links</option>' +
allTags.map(t=>`<option value="${escHtml(t.name)}"${t.name===current?' selected':''}>#${escHtml(t.name)}</option>`).join('');
const days = document.getElementById('agg-days').value;
const bots = document.getElementById('agg-bots').checked ? 1 : 0;
const tag = tagSel.value;
const content = document.getElementById('agg-content');
try {
let url = `${BASE}/api/analytics/aggregate?days=${days}&exclude_bots=${bots}`;
if (tag) url += `&tag=${encodeURIComponent(tag)}`;
const d = await (await authFetch(url)).json();
renderAudience(d);
} catch(e) {
content.innerHTML = '<div style="color:var(--muted);font-size:12px;font-family:var(--font-mono)">Could not load</div>';
}
}
function renderAudience(d) {
const content = document.getElementById('agg-content');
const hasCountries = d.countries && d.countries.length > 0;
const topLinksHtml = (d.top_links||[]).slice(0,5).map(l=>`
<div class="breakdown-row">
<div class="breakdown-label" title="${escHtml(l.title||l.long_url)}">/${escHtml(l.code)}</div>
<div class="breakdown-bar-wrap"><div class="breakdown-bar" style="width:${Math.round((l.count/Math.max(d.top_links[0].count,1))*100)}%"></div></div>
<div class="breakdown-count">${l.count}</div>
</div>`).join('') || '<div style="color:var(--muted);font-size:11px;font-family:var(--font-mono)">No data</div>';
content.innerHTML = `
<div style="display:flex;gap:18px;flex-wrap:wrap;font-family:var(--font-mono);font-size:11px;color:var(--muted);margin-bottom:8px;letter-spacing:.04em">
<span>PERIOD <b style="color:var(--text)">${d.period_clicks}</b> clicks</span>
<span>UNIQUE <b style="color:var(--text)">${d.unique_visitors??'—'}</b> visitors</span>
${d.tag?`<span>TAG <b style="color:var(--accent)">#${escHtml(d.tag)}</b></span>`:''}
<span style="opacity:.7">window capped at ${d.max_days}d on your plan</span>
</div>
<div class="chart-wrap"><canvas class="chart-canvas" id="agg-chart" height="70"></canvas></div>
<div class="breakdown-grid" style="grid-template-columns:${hasCountries?'1fr 1fr 1fr 1fr':'1fr 1fr 1fr'}">
<div class="breakdown-card"><h5>TOP LINKS</h5>${topLinksHtml}</div>
<div class="breakdown-card"><h5>REFERRERS</h5>${renderBreakdown(d.referrers.map(r=>({label:r.source,count:r.count})))}</div>
<div class="breakdown-card"><h5>DEVICES</h5>${renderBreakdown(d.devices.map(r=>({label:r.device,count:r.count})))}</div>
${hasCountries?`<div class="breakdown-card"><h5>COUNTRIES</h5>${renderBreakdown(d.countries.map(r=>({label:r.country,count:r.count})))}</div>`:''}
</div>`;
requestAnimationFrame(() => {
const canvas = document.getElementById('agg-chart');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio||1;
const W = canvas.offsetWidth, H = 70;
canvas.width = W*dpr; canvas.height = H*dpr; ctx.scale(dpr,dpr);
const data = d.daily, n = data.length;
const max = Math.max(...data.map(x=>x.clicks),1);
const barW = Math.max(2,(W/n)-2);
const gap = (W-barW*n)/(n-1||1);
const accentRgb = getComputedStyle(document.documentElement).getPropertyValue('--accent-rgb').trim()||'0,184,212';
data.forEach((pt,i)=>{
const x=i*(barW+gap), bH=(pt.clicks/max)*(H-14), y=H-bH-2;
ctx.fillStyle=`rgba(${accentRgb},${pt.clicks>0?.8:.15})`;
ctx.beginPath(); ctx.roundRect(x,y,barW,bH+2,[2,2,0,0]); ctx.fill();
});
ctx.fillStyle='rgba(107,107,128,.8)'; ctx.font='10px "Share Tech Mono"';
if (data.length>0) {
ctx.textAlign='left'; ctx.fillText(data[0].date.slice(5),0,H);
ctx.textAlign='right'; ctx.fillText(data[data.length-1].date.slice(5),W,H);
}
});
}
// ── API keys ────────────────────────────────────────
async function loadApiKeys() {
const container = document.getElementById('apikeys-list');
@@ -1672,15 +1770,19 @@ async function autoFetchTitleForEdit(code) {
}
// ── Analytics ──────────────────────────────────────
const analyticsDaysByCode = {};
async function loadAnalytics(code, days, btnEl) {
if (btnEl) {
document.querySelectorAll(`#analytics-${code} .day-btn`).forEach(b=>b.classList.remove('active'));
btnEl.classList.add('active');
}
analyticsDaysByCode[code] = days;
const excludeBots = document.getElementById(`bots-${code}`)?.checked ? 1 : 0;
const container = document.getElementById(`analytics-content-${code}`);
container.innerHTML = '<div style="color:var(--muted);font-size:12px;font-family:var(--font-mono);padding:10px 0">Loading...</div>';
try {
const d = await (await authFetch(`${BASE}/api/links/${code}/analytics?days=${days}`)).json();
const d = await (await authFetch(`${BASE}/api/links/${code}/analytics?days=${days}&exclude_bots=${excludeBots}`)).json();
renderAnalytics(code, d);
} catch(e) { container.innerHTML='<div style="color:var(--muted);font-size:12px;font-family:var(--font-mono)">Could not load</div>'; }
}
@@ -1696,7 +1798,15 @@ function renderAnalytics(code, d) {
? `<div class="breakdown-card"><h5>COUNTRIES</h5>${renderBreakdown(d.countries.map(r=>({label:r.country,count:r.count})))}</div>`
: '';
const statLine = `
<div style="display:flex;gap:18px;flex-wrap:wrap;font-family:var(--font-mono);font-size:11px;color:var(--muted);margin-bottom:8px;letter-spacing:.04em">
<span>PERIOD <b style="color:var(--text)">${d.period_clicks}</b> clicks</span>
<span>UNIQUE <b style="color:var(--text)">${d.unique_visitors??'—'}</b> visitors</span>
${d.bot_clicks ? `<span>BOTS <b style="color:var(--text)">${d.bot_clicks}</b>${d.exclude_bots?' (hidden)':''}</span>` : ''}
</div>`;
container.innerHTML = `
${statLine}
<div class="chart-wrap"><canvas class="chart-canvas" id="${chartId}" height="90"></canvas></div>
<div class="breakdown-grid" style="grid-template-columns:${hasCountries?'1fr 1fr 1fr 1fr':'1fr 1fr 1fr'}">
<div class="breakdown-card"><h5>REFERRERS</h5>${renderBreakdown(d.referrers.map(r=>({label:r.source,count:r.count})))}</div>