feat: add KPI trends, human-readable page names, and bounce rate context
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

1. KPI cards show trend arrows (▲▼ X%) comparing current vs previous period
2. Raw paths replaced with human-readable names throughout Pages and Paths tabs:
   - /company/pixlab-sp-z-o-o → PixLab sp. z o.o.
   - /kalendarz/45 → Spotkanie z posłami (event title from DB)
   - /login → Logowanie, /dashboard → Panel użytkownika etc.
3. Bounce rate threshold adjusted (85%+ warning instead of 70%)
   with tooltip explaining 70-85% is normal for membership portals
4. Column headers changed from technical (Ścieżka, Exit rate) to
   user-friendly (Strona, Wsp. wyjścia)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-03-10 19:27:44 +01:00
parent 21f699ce11
commit d7baa1e588
2 changed files with 158 additions and 59 deletions

View File

@ -24,7 +24,8 @@ from database import (
SessionLocal, User, UserSession, PageView, SearchQuery,
ConversionEvent, JSError, EmailLog, SecurityAlert,
AuditLog, AnalyticsDaily, SystemRole,
AIChatConversation, AIChatMessage, AIChatFeedback
AIChatConversation, AIChatMessage, AIChatFeedback,
Company, NordaEvent
)
from utils.decorators import role_required
@ -734,6 +735,75 @@ def _format_time(seconds):
return f'{hours}h {mins}m'
PATH_LABELS = {
'/': 'Strona główna',
'/login': 'Logowanie',
'/register': 'Rejestracja',
'/dashboard': 'Panel użytkownika',
'/search': 'Wyszukiwarka',
'/szukaj': 'Wyszukiwarka',
'/chat': 'NordaGPT',
'/forum': 'Forum',
'/forum/': 'Forum',
'/kalendarz/': 'Kalendarz',
'/tablica/': 'Tablica B2B',
'/ogloszenia': 'Aktualności',
'/wiadomosci': 'Wiadomości',
'/edukacja': 'Edukacja',
'/rada': 'Rada Izby',
'/korzysci': 'Korzyści członkostwa',
'/debugbar/': 'Debug Bar',
}
def _humanize_path(path, db=None, _cache={}):
"""Convert raw path to human-readable label."""
if not path:
return path
# Exact match
if path in PATH_LABELS:
return PATH_LABELS[path]
# Prefix match (e.g. /forum/topic/5 → Forum)
for prefix, label in PATH_LABELS.items():
if prefix.endswith('/') and path.startswith(prefix) and prefix != '/':
rest = path[len(prefix):]
if rest and not rest.startswith('admin'):
return f'{label}: {rest}'
return label
# Dynamic: /company/<slug> → Company name
import re
company_match = re.match(r'^/company/([a-z0-9-]+)$', path)
if company_match and db:
slug = company_match.group(1)
if slug not in _cache:
co = db.query(Company.name).filter_by(slug=slug).first()
_cache[slug] = co.name if co else slug
return _cache[slug]
# Dynamic: /kalendarz/<id> → Event title
event_match = re.match(r'^/kalendarz/(\d+)(/.*)?$', path)
if event_match and db:
eid = int(event_match.group(1))
suffix = event_match.group(2) or ''
cache_key = f'event_{eid}'
if cache_key not in _cache:
ev = db.query(NordaEvent.title).filter_by(id=eid).first()
_cache[cache_key] = ev.title if ev else f'Wydarzenie #{eid}'
name = _cache[cache_key]
if suffix == '/rsvp':
return f'{name} (RSVP)'
return name
# Admin paths
if path.startswith('/admin/'):
return path.replace('/admin/', 'Admin: ').replace('-', ' ').title()
return path
def _tab_pages(db, start_date, days):
"""Page popularity map."""
start_dt = datetime.combine(start_date, datetime.min.time())
@ -814,6 +884,7 @@ def _tab_pages(db, start_date, days):
avg_time_val = int(p.avg_time or 0)
pages_list.append({
'path': p.path,
'label': _humanize_path(p.path, db),
'views': p.views,
'unique_users': p.unique_users,
'avg_time': avg_time_val,
@ -983,10 +1054,10 @@ def _tab_paths(db, start_date, days):
max_exit_f = exit_filtered[0].cnt if exit_filtered else 1
return {
'entry_pages': [{'path': r.path, 'count': r.cnt, 'bar_pct': int(r.cnt / max_entry_f * 100)} for r in entry_filtered],
'exit_pages': [{'path': r.path, 'count': r.cnt, 'bar_pct': int(r.cnt / max_exit_f * 100)} for r in exit_filtered],
'transitions': [{'from': r.path, 'to': r.next_path, 'count': r.cnt} for r in transitions_filtered],
'dropoff': [{'path': r.path, 'views': r.views, 'exits': r.exits, 'exit_rate': float(r.exit_rate)} for r in dropoff_filtered],
'entry_pages': [{'path': r.path, 'label': _humanize_path(r.path, db), 'count': r.cnt, 'bar_pct': int(r.cnt / max_entry_f * 100)} for r in entry_filtered],
'exit_pages': [{'path': r.path, 'label': _humanize_path(r.path, db), 'count': r.cnt, 'bar_pct': int(r.cnt / max_exit_f * 100)} for r in exit_filtered],
'transitions': [{'from': r.path, 'from_label': _humanize_path(r.path, db), 'to': r.next_path, 'to_label': _humanize_path(r.next_path, db), 'count': r.cnt} for r in transitions_filtered],
'dropoff': [{'path': r.path, 'label': _humanize_path(r.path, db), 'views': r.views, 'exits': r.exits, 'exit_rate': float(r.exit_rate)} for r in dropoff_filtered],
'session_lengths': [{'bucket': r.bucket, 'count': r.cnt, 'bar_pct': int(r.cnt / max_sl * 100)} for r in session_lengths],
}
@ -995,43 +1066,61 @@ def _tab_paths(db, start_date, days):
# TAB 5: OVERVIEW
# ============================================================
def _kpi_for_period(db, start_dt, end_dt=None):
"""Calculate KPI metrics for a given period. Returns dict with raw values."""
filters_session = [UserSession.started_at >= start_dt, UserSession.is_bot == False]
filters_pv = [PageView.viewed_at >= start_dt, UserSession.is_bot == False]
if end_dt:
filters_session.append(UserSession.started_at < end_dt)
filters_pv.append(PageView.viewed_at < end_dt)
active = db.query(func.count(func.distinct(UserSession.user_id))).filter(
*filters_session, UserSession.user_id.isnot(None)
).scalar() or 0
sessions = db.query(func.count(UserSession.id)).filter(*filters_session).scalar() or 0
pvs = db.query(func.count(PageView.id)).join(
UserSession, PageView.session_id == UserSession.id
).filter(*filters_pv).scalar() or 0
single = db.query(func.count()).select_from(
db.query(PageView.session_id).join(
UserSession, PageView.session_id == UserSession.id
).filter(*filters_pv).group_by(PageView.session_id).having(
func.count(PageView.id) == 1
).subquery()
).scalar() or 0
bounce = round(single / sessions * 100) if sessions > 0 else 0
return {'active_users': active, 'sessions': sessions, 'pageviews': pvs, 'bounce_rate': bounce}
def _trend_pct(current, previous):
"""Calculate trend percentage. Returns int or None if no previous data."""
if not previous or previous == 0:
return None
return round((current - previous) / previous * 100)
def _tab_overview(db, start_date, days):
"""Overview charts - sessions, hourly heatmap, devices."""
filter_type = request.args.get('filter', 'all') # all, logged, anonymous
start_dt = datetime.combine(start_date, datetime.min.time())
start_30d = datetime.combine(date.today() - timedelta(days=30), datetime.min.time())
# KPI stat cards (period-based, bot-filtered)
active_users = db.query(func.count(func.distinct(UserSession.user_id))).filter(
UserSession.started_at >= start_dt,
UserSession.user_id.isnot(None),
UserSession.is_bot == False
).scalar() or 0
# KPI: current period
kpi_now = _kpi_for_period(db, start_dt)
total_sessions = db.query(func.count(UserSession.id)).filter(
UserSession.started_at >= start_dt,
UserSession.is_bot == False
).scalar() or 0
# KPI: previous period (same length, ending at start_dt)
prev_start = datetime.combine(start_date - timedelta(days=days), datetime.min.time())
kpi_prev = _kpi_for_period(db, prev_start, start_dt)
total_pageviews = db.query(func.count(PageView.id)).join(
UserSession, PageView.session_id == UserSession.id
).filter(
PageView.viewed_at >= start_dt,
UserSession.is_bot == False
).scalar() or 0
# Bounce rate: sessions with only 1 page view / total sessions
single_pv_sessions = db.query(func.count()).select_from(
db.query(PageView.session_id).join(
UserSession, PageView.session_id == UserSession.id
).filter(
PageView.viewed_at >= start_dt,
UserSession.is_bot == False
).group_by(PageView.session_id).having(
func.count(PageView.id) == 1
).subquery()
).scalar() or 0
bounce_rate = round(single_pv_sessions / total_sessions * 100) if total_sessions > 0 else 0
# Attach trends
kpi = {}
for key in ('active_users', 'sessions', 'pageviews', 'bounce_rate'):
kpi[key] = kpi_now[key]
kpi[f'{key}_trend'] = _trend_pct(kpi_now[key], kpi_prev[key])
# Daily sessions from analytics_daily (already bot-filtered after migration)
daily_data = db.query(AnalyticsDaily).filter(

View File

@ -547,7 +547,7 @@
<table class="data-table">
<thead>
<tr>
<th>Ścieżka</th>
<th>Strona</th>
<th style="width: 200px;">Odsłony</th>
<th>Unikalni</th>
<th>Śr. czas</th>
@ -558,7 +558,7 @@
<tbody>
{% for p in data.top_pages %}
<tr>
<td style="max-width: 300px; word-break: break-all; font-family: monospace; font-size: var(--font-size-xs);">{{ p.path }}</td>
<td style="max-width: 300px; word-break: break-all; font-size: var(--font-size-xs);" title="{{ p.path }}">{{ p.label }}</td>
<td>
<div style="display: flex; align-items: center; gap: var(--spacing-sm);">
<div class="bar-container" style="flex: 1;">
@ -664,7 +664,7 @@
<h2>Strony wejściowe (top 10)</h2>
{% for p in data.entry_pages %}
<div class="bar-chart-row">
<div class="bar-chart-label" style="width: 200px; min-width: 200px; font-family: monospace; font-size: var(--font-size-xs); word-break: break-all;">{{ p.path }}</div>
<div class="bar-chart-label" style="width: 200px; min-width: 200px; font-size: var(--font-size-xs); word-break: break-all;" title="{{ p.path }}">{{ p.label }}</div>
<div class="bar-chart-bar">
<div class="bar-chart-fill" style="width: {{ p.bar_pct }}%; background: #22c55e;">
<span>{{ p.count }}</span>
@ -679,7 +679,7 @@
<h2>Strony wyjściowe (top 10)</h2>
{% for p in data.exit_pages %}
<div class="bar-chart-row">
<div class="bar-chart-label" style="width: 200px; min-width: 200px; font-family: monospace; font-size: var(--font-size-xs); word-break: break-all;">{{ p.path }}</div>
<div class="bar-chart-label" style="width: 200px; min-width: 200px; font-size: var(--font-size-xs); word-break: break-all;" title="{{ p.path }}">{{ p.label }}</div>
<div class="bar-chart-bar">
<div class="bar-chart-fill" style="width: {{ p.bar_pct }}%; background: #ef4444;">
<span>{{ p.count }}</span>
@ -706,9 +706,9 @@
<tbody>
{% for t in data.transitions %}
<tr>
<td style="font-family: monospace; font-size: var(--font-size-xs);">{{ t.from }}</td>
<td style="font-size: var(--font-size-xs);" title="{{ t.from }}">{{ t.from_label }}</td>
<td class="transition-arrow" style="text-align: center;"></td>
<td style="font-family: monospace; font-size: var(--font-size-xs);">{{ t.to }}</td>
<td style="font-size: var(--font-size-xs);" title="{{ t.to }}">{{ t.to_label }}</td>
<td style="font-weight: 600; text-align: right;">{{ t.count }}</td>
</tr>
{% endfor %}
@ -725,16 +725,16 @@
<table class="data-table">
<thead>
<tr>
<th>Ścieżka</th>
<th>Strona</th>
<th>Odsłony</th>
<th>Wyjścia</th>
<th>Exit rate</th>
<th>Wsp. wyjścia</th>
</tr>
</thead>
<tbody>
{% for d in data.dropoff %}
<tr>
<td style="font-family: monospace; font-size: var(--font-size-xs);">{{ d.path }}</td>
<td style="font-size: var(--font-size-xs);" title="{{ d.path }}">{{ d.label }}</td>
<td>{{ d.views }}</td>
<td>{{ d.exits }}</td>
<td>
@ -770,24 +770,34 @@
<!-- ============================================================ -->
{% elif tab == 'overview' %}
<!-- KPI Stat Cards -->
<!-- KPI Stat Cards with Trends -->
<div class="stats-grid">
<div class="stat-card success">
<div class="stat-value">{{ data.kpi.active_users }}</div>
<div class="stat-label">Aktywni użytkownicy</div>
</div>
<div class="stat-card info">
<div class="stat-value">{{ data.kpi.sessions }}</div>
<div class="stat-label">Sesje</div>
</div>
<div class="stat-card info">
<div class="stat-value">{{ data.kpi.pageviews }}</div>
<div class="stat-label">Odsłony</div>
</div>
<div class="stat-card {% if data.kpi.bounce_rate > 70 %}error{% elif data.kpi.bounce_rate > 50 %}warning{% else %}success{% endif %}">
<div class="stat-value">{{ data.kpi.bounce_rate }}%</div>
<div class="stat-label">Współczynnik odrzuceń</div>
{% for kpi_item in [
('active_users', 'Aktywni użytkownicy', 'success', false),
('sessions', 'Sesje', 'info', false),
('pageviews', 'Odsłony', 'info', false),
('bounce_rate', 'Współczynnik odrzuceń', 'info', true),
] %}
{% set key = kpi_item[0] %}
{% set label = kpi_item[1] %}
{% set is_bounce = kpi_item[3] %}
{% set val = data.kpi[key] %}
{% set trend = data.kpi[key ~ '_trend'] %}
{% if is_bounce %}
<div class="stat-card {% if val > 85 %}warning{% else %}success{% endif %}" title="Na portalu członkowskim 70-85% jest normalne — użytkownicy sprawdzają jedną informację i wychodzą">
{% else %}
<div class="stat-card {{ kpi_item[2] }}">
{% endif %}
<div class="stat-value">{{ val }}{% if is_bounce %}%{% endif %}
{% if trend is not none %}
<span style="font-size: var(--font-size-sm); margin-left: 6px; {% if is_bounce %}color: {{ 'var(--success)' if trend <= 0 else 'var(--error)' }}{% else %}color: {{ 'var(--success)' if trend >= 0 else 'var(--error)' }}{% endif %}">
{{ '▲' if trend > 0 else ('▼' if trend < 0 else '') }} {{ trend|abs }}%
</span>
{% endif %}
</div>
<div class="stat-label">{{ label }}</div>
</div>
{% endfor %}
</div>
<!-- Filter -->