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
+129 -12
View File
@@ -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
# ─────────────────────────────────────────────