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:
@@ -1217,24 +1217,40 @@ def link_analytics(code):
|
||||
plan = get_user_plan(conn, session.get('user_id'))
|
||||
max_days = PLAN_LIMITS[plan]['analytics_days']
|
||||
days = min(int(request.args.get('days', 30)), max_days)
|
||||
exclude_bots = str(request.args.get('exclude_bots') or '').lower() in ('1', 'true')
|
||||
bot_clause = ' AND COALESCE(is_bot,0)=0' if exclude_bots else ''
|
||||
|
||||
link_id = link['id']
|
||||
since = (datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days)).isoformat()
|
||||
|
||||
daily_rows = conn.execute("""
|
||||
SELECT substr(clicked_at,1,10) as day, COUNT(*) as count
|
||||
FROM clicks WHERE link_id=? AND clicked_at>=?
|
||||
daily_rows = conn.execute(f"""
|
||||
SELECT substr(clicked_at,1,10) as day, COUNT(*) as count,
|
||||
COUNT(DISTINCT NULLIF(ip_address,'')) as uniq
|
||||
FROM clicks WHERE link_id=? AND clicked_at>=?{bot_clause}
|
||||
GROUP BY day ORDER BY day
|
||||
""", (link_id, since)).fetchall()
|
||||
daily_map = {r['day']: r['count'] for r in daily_rows}
|
||||
daily_map = {r['day']: r['count'] for r in daily_rows}
|
||||
uniq_map = {r['day']: r['uniq'] for r in daily_rows}
|
||||
daily = [
|
||||
{'date': (datetime.now(timezone.utc).replace(tzinfo=None)-timedelta(days=days-1-i)).strftime('%Y-%m-%d'), 'clicks': 0}
|
||||
{'date': (datetime.now(timezone.utc).replace(tzinfo=None)-timedelta(days=days-1-i)).strftime('%Y-%m-%d'), 'clicks': 0, 'unique': 0}
|
||||
for i in range(days)
|
||||
]
|
||||
for d in daily: d['clicks'] = daily_map.get(d['date'], 0)
|
||||
for d in daily:
|
||||
d['clicks'] = daily_map.get(d['date'], 0)
|
||||
d['unique'] = uniq_map.get(d['date'], 0)
|
||||
|
||||
unique_visitors = conn.execute(
|
||||
f"SELECT COUNT(DISTINCT NULLIF(ip_address,'')) FROM clicks "
|
||||
f"WHERE link_id=? AND clicked_at>=?{bot_clause}",
|
||||
(link_id, since)
|
||||
).fetchone()[0]
|
||||
bot_clicks = conn.execute(
|
||||
'SELECT COUNT(*) FROM clicks WHERE link_id=? AND clicked_at>=? AND COALESCE(is_bot,0)=1',
|
||||
(link_id, since)
|
||||
).fetchone()[0]
|
||||
|
||||
ref_rows = conn.execute(
|
||||
'SELECT referrer, COUNT(*) as count FROM clicks WHERE link_id=? AND clicked_at>=? GROUP BY referrer',
|
||||
f'SELECT referrer, COUNT(*) as count FROM clicks WHERE link_id=? AND clicked_at>=?{bot_clause} GROUP BY referrer',
|
||||
(link_id, since)
|
||||
).fetchall()
|
||||
referrers = {}
|
||||
@@ -1243,7 +1259,7 @@ def link_analytics(code):
|
||||
referrers = [{'source':k,'count':v} for k,v in sorted(referrers.items(), key=lambda x:-x[1])]
|
||||
|
||||
ua_rows = conn.execute(
|
||||
'SELECT user_agent, COUNT(*) as count FROM clicks WHERE link_id=? AND clicked_at>=? GROUP BY user_agent',
|
||||
f'SELECT user_agent, COUNT(*) as count FROM clicks WHERE link_id=? AND clicked_at>=?{bot_clause} GROUP BY user_agent',
|
||||
(link_id, since)
|
||||
).fetchall()
|
||||
devices = {}; browsers = {}
|
||||
@@ -1256,11 +1272,11 @@ def link_analytics(code):
|
||||
|
||||
# Hourly heatmap: 7 days-of-week × 24 hours
|
||||
# SQLite strftime('%w') returns 0=Sunday … 6=Saturday; we map to 0=Monday … 6=Sunday
|
||||
hourly_rows = conn.execute("""
|
||||
hourly_rows = conn.execute(f"""
|
||||
SELECT CAST(strftime('%w', clicked_at) AS INTEGER) as dow,
|
||||
CAST(strftime('%H', clicked_at) AS INTEGER) as hr,
|
||||
COUNT(*) as count
|
||||
FROM clicks WHERE link_id=? AND clicked_at>=?
|
||||
FROM clicks WHERE link_id=? AND clicked_at>=?{bot_clause}
|
||||
GROUP BY dow, hr
|
||||
""", (link_id, since)).fetchall()
|
||||
# heatmap[day_of_week 0=Mon][hour 0-23]
|
||||
@@ -1270,22 +1286,123 @@ def link_analytics(code):
|
||||
heatmap[mon_dow][r['hr']] = r['count']
|
||||
|
||||
# Geographic breakdown
|
||||
country_rows = conn.execute("""
|
||||
country_rows = conn.execute(f"""
|
||||
SELECT COALESCE(NULLIF(country,''),'Unknown') as country, COUNT(*) as count
|
||||
FROM clicks WHERE link_id=? AND clicked_at>=?
|
||||
FROM clicks WHERE link_id=? AND clicked_at>=?{bot_clause}
|
||||
GROUP BY country ORDER BY count DESC LIMIT 20
|
||||
""", (link_id, since)).fetchall()
|
||||
countries = [{'country': r['country'], 'count': r['count']} for r in country_rows]
|
||||
|
||||
return jsonify({
|
||||
'code': code, 'days': days, 'max_days': max_days,
|
||||
'exclude_bots': exclude_bots,
|
||||
'total_clicks': link['clicks'],
|
||||
'period_clicks': sum(d['clicks'] for d in daily),
|
||||
'unique_visitors': unique_visitors,
|
||||
'bot_clicks': bot_clicks,
|
||||
'daily': daily, 'referrers': referrers, 'devices': devices, 'browsers': browsers,
|
||||
'heatmap': heatmap, 'countries': countries,
|
||||
})
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Aggregate analytics — across the whole account (or org), optionally
|
||||
# filtered to a tag. Powers the dashboard "audience" panel and lets
|
||||
# campaign runners see one chart for N tagged links.
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
@app.route('/api/analytics/aggregate')
|
||||
@login_required
|
||||
def aggregate_analytics():
|
||||
user_id = session.get('user_id')
|
||||
is_admin = session.get('is_admin', False)
|
||||
tag = (request.args.get('tag') or '').strip().lower()
|
||||
exclude_bots = str(request.args.get('exclude_bots') or '').lower() in ('1', 'true')
|
||||
bot_clause = ' AND COALESCE(c.is_bot,0)=0' if exclude_bots else ''
|
||||
|
||||
with get_db() as conn:
|
||||
plan = get_user_plan(conn, user_id)
|
||||
max_days = PLAN_LIMITS[plan]['analytics_days']
|
||||
days = min(int(request.args.get('days', 30)), max_days)
|
||||
since = (datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days)).isoformat()
|
||||
|
||||
org_id = get_user_org_id(conn, user_id)
|
||||
if org_id and not is_admin:
|
||||
scope_clause = 'l.user_id IN (SELECT id FROM users WHERE org_id=?)'
|
||||
scope_params = [org_id]
|
||||
else:
|
||||
scope_clause = 'l.user_id=?'
|
||||
scope_params = [user_id]
|
||||
|
||||
where = f'l.is_active=1 AND {scope_clause} AND c.clicked_at>=?{bot_clause}'
|
||||
params = list(scope_params) + [since]
|
||||
if tag:
|
||||
where += (' AND l.id IN (SELECT lt.link_id FROM link_tags lt '
|
||||
'JOIN tags t ON lt.tag_id=t.id WHERE t.name=?)')
|
||||
params.append(tag)
|
||||
|
||||
base = f'FROM clicks c JOIN links l ON c.link_id=l.id WHERE {where}'
|
||||
|
||||
daily_rows = conn.execute(f"""
|
||||
SELECT substr(c.clicked_at,1,10) as day, COUNT(*) as count,
|
||||
COUNT(DISTINCT NULLIF(c.ip_address,'')) as uniq
|
||||
{base} GROUP BY day ORDER BY day
|
||||
""", params).fetchall()
|
||||
daily_map = {r['day']: r['count'] for r in daily_rows}
|
||||
uniq_map = {r['day']: r['uniq'] for r in daily_rows}
|
||||
daily = [
|
||||
{'date': (datetime.now(timezone.utc).replace(tzinfo=None)-timedelta(days=days-1-i)).strftime('%Y-%m-%d'), 'clicks': 0, 'unique': 0}
|
||||
for i in range(days)
|
||||
]
|
||||
for d in daily:
|
||||
d['clicks'] = daily_map.get(d['date'], 0)
|
||||
d['unique'] = uniq_map.get(d['date'], 0)
|
||||
|
||||
unique_visitors = conn.execute(
|
||||
f"SELECT COUNT(DISTINCT NULLIF(c.ip_address,'')) {base}", params
|
||||
).fetchone()[0]
|
||||
|
||||
ref_rows = conn.execute(
|
||||
f'SELECT c.referrer, COUNT(*) as count {base} GROUP BY c.referrer', params
|
||||
).fetchall()
|
||||
referrers = {}
|
||||
for r in ref_rows:
|
||||
b = parse_referrer(r['referrer']); referrers[b] = referrers.get(b,0) + r['count']
|
||||
referrers = [{'source':k,'count':v} for k,v in sorted(referrers.items(), key=lambda x:-x[1])]
|
||||
|
||||
ua_rows = conn.execute(
|
||||
f'SELECT c.user_agent, COUNT(*) as count {base} GROUP BY c.user_agent', params
|
||||
).fetchall()
|
||||
devices = {}; browsers = {}
|
||||
for r in ua_rows:
|
||||
ua = r['user_agent'] or ''
|
||||
devices[parse_device(ua)] = devices.get(parse_device(ua),0) + r['count']
|
||||
browsers[parse_browser(ua)] = browsers.get(parse_browser(ua),0) + r['count']
|
||||
devices = [{'device':k,'count':v} for k,v in sorted(devices.items(), key=lambda x:-x[1])]
|
||||
browsers = [{'browser':k,'count':v} for k,v in sorted(browsers.items(), key=lambda x:-x[1])]
|
||||
|
||||
country_rows = conn.execute(f"""
|
||||
SELECT COALESCE(NULLIF(c.country,''),'Unknown') as country, COUNT(*) as count
|
||||
{base} GROUP BY country ORDER BY count DESC LIMIT 20
|
||||
""", params).fetchall()
|
||||
countries = [{'country': r['country'], 'count': r['count']} for r in country_rows]
|
||||
|
||||
top_rows = conn.execute(f"""
|
||||
SELECT l.code, l.title, l.long_url, COUNT(*) as count
|
||||
{base} GROUP BY l.id ORDER BY count DESC LIMIT 10
|
||||
""", params).fetchall()
|
||||
top_links = [dict(r) for r in top_rows]
|
||||
|
||||
return jsonify({
|
||||
'days': days, 'max_days': max_days, 'tag': tag or None,
|
||||
'exclude_bots': exclude_bots,
|
||||
'period_clicks': sum(d['clicks'] for d in daily),
|
||||
'unique_visitors': unique_visitors,
|
||||
'daily': daily, 'referrers': referrers, 'devices': devices,
|
||||
'browsers': browsers, 'countries': countries, 'top_links': top_links,
|
||||
})
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Click-event CSV export
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
+111
-1
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user