From a321ff61714ff895b0d75ccb43bcf1da9ad8e677 Mon Sep 17 00:00:00 2001 From: Jason Stedwell Date: Fri, 3 Jul 2026 01:40:14 -0500 Subject: [PATCH] feat: unique visitors, bot filtering, and account-wide audience analytics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app.py | 141 ++++++++++++++++++++++++++++++++++++++++++++++++----- index.html | 112 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 240 insertions(+), 13 deletions(-) diff --git a/app.py b/app.py index e7a6901..f3dc8f7 100644 --- a/app.py +++ b/app.py @@ -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 # ───────────────────────────────────────────── diff --git a/index.html b/index.html index 82d190d..9014826 100644 --- a/index.html +++ b/index.html @@ -635,6 +635,27 @@ input[type="range"]::-webkit-slider-thumb { -webkit-appearance:none; width:16px; + + +
@@ -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 = '' + + allTags.map(t=>``).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 = '
Could not load
'; + } +} + +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=>` +
+
/${escHtml(l.code)}
+
+
${l.count}
+
`).join('') || '
No data
'; + + content.innerHTML = ` +
+ PERIOD ${d.period_clicks} clicks + UNIQUE ${d.unique_visitors??'—'} visitors + ${d.tag?`TAG #${escHtml(d.tag)}`:''} + window capped at ${d.max_days}d on your plan +
+
+
+
TOP LINKS
${topLinksHtml}
+
REFERRERS
${renderBreakdown(d.referrers.map(r=>({label:r.source,count:r.count})))}
+
DEVICES
${renderBreakdown(d.devices.map(r=>({label:r.device,count:r.count})))}
+ ${hasCountries?`
COUNTRIES
${renderBreakdown(d.countries.map(r=>({label:r.country,count:r.count})))}
`:''} +
`; + + 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 = '
Loading...
'; 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='
Could not load
'; } } @@ -1696,7 +1798,15 @@ function renderAnalytics(code, d) { ? `
COUNTRIES
${renderBreakdown(d.countries.map(r=>({label:r.country,count:r.count})))}
` : ''; + const statLine = ` +
+ PERIOD ${d.period_clicks} clicks + UNIQUE ${d.unique_visitors??'—'} visitors + ${d.bot_clicks ? `BOTS ${d.bot_clicks}${d.exclude_bots?' (hidden)':''}` : ''} +
`; + container.innerHTML = ` + ${statLine}
REFERRERS
${renderBreakdown(d.referrers.map(r=>({label:r.source,count:r.count})))}