feat: add traffic sources, company popularity, search CTR, and content open rates to analytics
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions

New insights for admin dashboard:
- Referrer sources (top 10 by domain, 30 days) in Overview tab
- Company profile popularity ranking (views + unique viewers)
- Search effectiveness: CTR, avg click position, top clicked companies
- Content open rates: announcements, forum, classifieds (published vs read)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-03-10 20:06:18 +01:00
parent 7aa8b8bdc3
commit f46bf101a0
2 changed files with 286 additions and 1 deletions

View File

@ -25,7 +25,8 @@ from database import (
ConversionEvent, JSError, EmailLog, SecurityAlert,
AuditLog, AnalyticsDaily, SystemRole,
AIChatConversation, AIChatMessage, AIChatFeedback,
Company, NordaEvent
Company, NordaEvent, Announcement, AnnouncementRead,
ForumTopic, ForumTopicRead, Classified, ClassifiedRead
)
from utils.decorators import role_required
@ -925,12 +926,105 @@ def _tab_pages(db, start_date, days):
func.date(SearchQuery.searched_at) >= start_date
).group_by(SearchQuery.query_normalized).order_by(desc('count')).limit(10).all()
# Search effectiveness (CTR)
total_searches = db.query(func.count(SearchQuery.id)).filter(
func.date(SearchQuery.searched_at) >= start_date
).scalar() or 0
searches_with_click = db.query(func.count(SearchQuery.id)).filter(
func.date(SearchQuery.searched_at) >= start_date,
SearchQuery.clicked_result_position.isnot(None)
).scalar() or 0
search_ctr = round(searches_with_click / total_searches * 100) if total_searches > 0 else 0
avg_click_position = db.query(func.avg(SearchQuery.clicked_result_position)).filter(
func.date(SearchQuery.searched_at) >= start_date,
SearchQuery.clicked_result_position.isnot(None)
).scalar()
avg_click_position = round(avg_click_position, 1) if avg_click_position else None
# Most clicked companies from search
top_clicked_companies = db.query(
SearchQuery.clicked_company_id,
Company.name,
func.count(SearchQuery.id).label('clicks')
).join(Company, SearchQuery.clicked_company_id == Company.id).filter(
func.date(SearchQuery.searched_at) >= start_date,
SearchQuery.clicked_company_id.isnot(None)
).group_by(SearchQuery.clicked_company_id, Company.name).order_by(desc('clicks')).limit(10).all()
search_effectiveness = {
'total': total_searches,
'with_click': searches_with_click,
'ctr': search_ctr,
'avg_position': avg_click_position,
'top_clicked': [{'name': r.name, 'clicks': r.clicks} for r in top_clicked_companies],
}
# Content open rates (30 days)
start_30d = datetime.combine(date.today() - timedelta(days=30), datetime.min.time())
# Announcements: total vs read
total_announcements = db.query(func.count(Announcement.id)).filter(
Announcement.created_at >= start_30d
).scalar() or 0
announcement_reads = db.query(func.count(func.distinct(AnnouncementRead.announcement_id))).filter(
AnnouncementRead.read_at >= start_30d
).scalar() or 0
total_announcement_readers = db.query(func.count(func.distinct(AnnouncementRead.user_id))).filter(
AnnouncementRead.read_at >= start_30d
).scalar() or 0
# Forum: topics published vs read
total_forum_topics = db.query(func.count(ForumTopic.id)).filter(
ForumTopic.created_at >= start_30d
).scalar() or 0
forum_reads_count = db.query(func.count(func.distinct(ForumTopicRead.topic_id))).filter(
ForumTopicRead.read_at >= start_30d
).scalar() or 0
total_forum_readers = db.query(func.count(func.distinct(ForumTopicRead.user_id))).filter(
ForumTopicRead.read_at >= start_30d
).scalar() or 0
# Classifieds: published vs read
total_classifieds = db.query(func.count(Classified.id)).filter(
Classified.created_at >= start_30d
).scalar() or 0
classified_reads_count = db.query(func.count(func.distinct(ClassifiedRead.classified_id))).filter(
ClassifiedRead.read_at >= start_30d
).scalar() or 0
total_classified_readers = db.query(func.count(func.distinct(ClassifiedRead.user_id))).filter(
ClassifiedRead.read_at >= start_30d
).scalar() or 0
content_engagement = {
'announcements': {
'published': total_announcements,
'read_by': total_announcement_readers,
'unique_read': announcement_reads,
'open_rate': round(announcement_reads / total_announcements * 100) if total_announcements > 0 else 0,
},
'forum': {
'published': total_forum_topics,
'read_by': total_forum_readers,
'unique_read': forum_reads_count,
'open_rate': round(forum_reads_count / total_forum_topics * 100) if total_forum_topics > 0 else 0,
},
'classifieds': {
'published': total_classifieds,
'read_by': total_classified_readers,
'unique_read': classified_reads_count,
'open_rate': round(classified_reads_count / total_classifieds * 100) if total_classifieds > 0 else 0,
},
}
return {
'sections': sections,
'top_pages': pages_list,
'ignored_pages': [{'path': _mask_token_path(p.path), 'views': p.views} for p in ignored],
'top_searches': search_query,
'searches_no_results': no_results_query,
'search_effectiveness': search_effectiveness,
'content_engagement': content_engagement,
}
@ -1298,6 +1392,68 @@ def _tab_overview(db, start_date, days):
'rsvp': rsvp_map.get(r.id, 0),
})
# Referrer sources (top 10, 30 days, exclude bots + internal)
referrer_raw = db.query(
PageView.referrer,
func.count(PageView.id).label('cnt')
).join(UserSession, PageView.session_id == UserSession.id).filter(
PageView.viewed_at >= start_30d,
PageView.referrer.isnot(None),
PageView.referrer != '',
UserSession.is_bot == False
).group_by(PageView.referrer).order_by(desc('cnt')).limit(50).all()
# Aggregate by domain
from urllib.parse import urlparse
domain_counts = {}
for r in referrer_raw:
try:
domain = urlparse(r.referrer).netloc or r.referrer
# Skip own domain
if 'nordabiznes.pl' in domain or 'nordabiznes' in domain:
continue
# Simplify well-known domains
if 'facebook' in domain or 'fbclid' in (r.referrer or ''):
domain = 'Facebook'
elif 'google' in domain:
domain = 'Google'
elif 'linkedin' in domain:
domain = 'LinkedIn'
elif 'instagram' in domain:
domain = 'Instagram'
elif 't.co' in domain or 'twitter' in domain:
domain = 'X / Twitter'
domain_counts[domain] = domain_counts.get(domain, 0) + r.cnt
except Exception:
pass
referrer_sources = sorted(domain_counts.items(), key=lambda x: x[1], reverse=True)[:10]
max_ref = referrer_sources[0][1] if referrer_sources else 1
# Company profile popularity (top 15, 30 days)
company_views_raw = db.query(
PageView.path,
func.count(PageView.id).label('views'),
func.count(func.distinct(PageView.user_id)).label('unique_viewers')
).join(UserSession, PageView.session_id == UserSession.id).filter(
PageView.viewed_at >= start_30d,
PageView.path.like('/company/%'),
UserSession.is_bot == False
).group_by(PageView.path).order_by(desc('views')).limit(20).all()
company_popularity = []
for r in company_views_raw:
if _is_technical_path(r.path):
continue
label = _humanize_path(r.path, db)
company_popularity.append({
'name': label,
'path': r.path,
'views': r.views,
'unique_viewers': r.unique_viewers,
})
company_popularity = company_popularity[:15]
max_company = company_popularity[0]['views'] if company_popularity else 1
return {
'filter_type': filter_type,
'kpi': kpi,
@ -1316,6 +1472,9 @@ def _tab_overview(db, start_date, days):
},
'adoption': adoption,
'events_comparison': events_comparison,
'referrer_sources': [{'domain': d, 'count': c, 'bar_pct': int(c / max_ref * 100)} for d, c in referrer_sources],
'company_popularity': company_popularity,
'max_company_views': max_company,
}

View File

@ -653,6 +653,73 @@
</div>
</div>
<!-- Search Effectiveness -->
<div class="section-card">
<h2>Skuteczność wyszukiwarki</h2>
<div class="stats-grid" style="grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); margin-bottom: var(--spacing-lg);">
<div class="stat-card info">
<div class="stat-value" style="font-size: var(--font-size-xl);">{{ data.search_effectiveness.total }}</div>
<div class="stat-label">Wyszukiwań</div>
</div>
<div class="stat-card {% if data.search_effectiveness.ctr >= 40 %}success{% elif data.search_effectiveness.ctr >= 20 %}warning{% else %}error{% endif %}">
<div class="stat-value" style="font-size: var(--font-size-xl);">{{ data.search_effectiveness.ctr }}%</div>
<div class="stat-label">Klikalność (CTR)</div>
</div>
<div class="stat-card info">
<div class="stat-value" style="font-size: var(--font-size-xl);">{{ data.search_effectiveness.avg_position or '—' }}</div>
<div class="stat-label">Śr. pozycja kliknięcia</div>
</div>
<div class="stat-card {% if data.search_effectiveness.ctr < 20 %}error{% else %}success{% endif %}">
<div class="stat-value" style="font-size: var(--font-size-xl);">{{ data.search_effectiveness.with_click }}</div>
<div class="stat-label">Z kliknięciem</div>
</div>
</div>
{% if data.search_effectiveness.top_clicked %}
<h3 style="font-size: var(--font-size-sm); font-weight: 600; margin-bottom: var(--spacing-sm); color: var(--text-secondary);">Najczęściej wybierane firmy z wyszukiwarki</h3>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
{% for c in data.search_effectiveness.top_clicked %}
<span style="padding: 4px 12px; background: var(--background); border-radius: var(--radius); font-size: var(--font-size-sm);">
<strong>{{ c.name }}</strong> <span style="color: var(--text-muted);">({{ c.clicks }}x)</span>
</span>
{% endfor %}
</div>
{% endif %}
</div>
<!-- Content Engagement (Open Rates) -->
<div class="section-card">
<h2>Zasięg treści <small style="font-weight: 400; color: var(--text-muted); font-size: var(--font-size-sm);">(30 dni)</small></h2>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: var(--spacing-lg);">
{% set content_types = [
('Aktualności', data.content_engagement.announcements, '#3b82f6'),
('Forum', data.content_engagement.forum, '#8b5cf6'),
('Tablica B2B', data.content_engagement.classifieds, '#10b981')
] %}
{% for label, stats, color in content_types %}
<div style="background: var(--background); padding: var(--spacing-lg); border-radius: var(--radius-lg); border-left: 4px solid {{ color }};">
<h3 style="font-size: var(--font-size-base); font-weight: 600; margin-bottom: var(--spacing-md);">{{ label }}</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: var(--spacing-sm); margin-bottom: var(--spacing-md);">
<div>
<div style="font-size: var(--font-size-2xl); font-weight: 700;">{{ stats.published }}</div>
<div style="font-size: var(--font-size-xs); color: var(--text-muted);">opublikowanych</div>
</div>
<div>
<div style="font-size: var(--font-size-2xl); font-weight: 700;">{{ stats.read_by }}</div>
<div style="font-size: var(--font-size-xs); color: var(--text-muted);">czytelników</div>
</div>
</div>
<div style="margin-bottom: 4px; display: flex; justify-content: space-between;">
<span style="font-size: var(--font-size-xs); color: var(--text-secondary);">Open rate</span>
<span style="font-size: var(--font-size-sm); font-weight: 600; color: {{ '#16a34a' if stats.open_rate >= 50 else ('#f59e0b' if stats.open_rate >= 25 else '#ef4444') }};">{{ stats.open_rate }}%</span>
</div>
<div style="height: 8px; background: #e2e8f0; border-radius: 4px; overflow: hidden;">
<div style="height: 100%; width: {{ stats.open_rate }}%; background: {{ color }}; border-radius: 4px;"></div>
</div>
</div>
{% endfor %}
</div>
</div>
<!-- ============================================================ -->
<!-- TAB: PATHS -->
<!-- ============================================================ -->
@ -929,6 +996,65 @@
</div>
</div>
<!-- Referrer Sources + Company Popularity -->
<div class="two-columns">
<!-- Referrer Sources -->
<div class="section-card">
<h2>Skąd przychodzą użytkownicy <small style="font-weight: 400; color: var(--text-muted); font-size: var(--font-size-sm);">(30 dni)</small></h2>
{% if data.referrer_sources %}
{% for r in data.referrer_sources %}
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 8px;">
<div style="width: 110px; font-size: var(--font-size-sm); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="{{ r.domain }}">{{ r.domain }}</div>
<div style="flex: 1; height: 18px; background: #e2e8f0; border-radius: 4px; overflow: hidden;">
<div style="height: 100%; width: {{ r.bar_pct }}%; background: #6366f1; border-radius: 4px;"></div>
</div>
<div style="width: 45px; font-size: var(--font-size-xs); color: var(--text-muted); text-align: right;">{{ r.count }}</div>
</div>
{% endfor %}
{% else %}
<p style="color: var(--text-muted); text-align: center; padding: var(--spacing-lg);">Brak danych o źródłach ruchu</p>
{% endif %}
</div>
<!-- Company Profile Popularity -->
<div class="section-card">
<h2>Popularność profili firm <small style="font-weight: 400; color: var(--text-muted); font-size: var(--font-size-sm);">(30 dni)</small></h2>
{% if data.company_popularity %}
<div class="table-scroll" style="max-height: 350px;">
<table class="data-table" style="font-size: var(--font-size-sm);">
<thead>
<tr>
<th>Firma</th>
<th style="text-align: center;">Odsłony</th>
<th style="text-align: center;">Osoby</th>
</tr>
</thead>
<tbody>
{% for c in data.company_popularity %}
<tr>
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis;">
<a href="{{ c.path }}" target="_blank" style="color: var(--primary); text-decoration: none;">{{ c.name }}</a>
</td>
<td style="text-align: center;">
<div style="display: flex; align-items: center; gap: 6px; justify-content: center;">
<div style="width: 60px; height: 12px; background: #e2e8f0; border-radius: 3px; overflow: hidden;">
<div style="height: 100%; width: {{ (c.views / data.max_company_views * 100)|int }}%; background: #10b981; border-radius: 3px;"></div>
</div>
{{ c.views }}
</div>
</td>
<td style="text-align: center;">{{ c.unique_viewers }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p style="color: var(--text-muted); text-align: center; padding: var(--spacing-lg);">Brak danych o profilach firm</p>
{% endif %}
</div>
</div>
<!-- ============================================================ -->
<!-- TAB: CHAT & CONVERSIONS -->
<!-- ============================================================ -->