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
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:
parent
21f699ce11
commit
d7baa1e588
@ -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(
|
||||
|
||||
@ -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 -->
|
||||
|
||||
Loading…
Reference in New Issue
Block a user