nordabiz/templates/admin/user_insights.html
Maciej Pienczyn 2aefbbf331
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
fix: eliminate N+1 queries in User Insights, add bot filtering to profile, UX improvements
- _tab_problems: 750 queries → ~10 batch queries with GROUP BY
- _tab_engagement: 2550 queries → ~12 batch queries, sparkline in 1 query
- user_insights_profile: 60+ queries → batch trend (2 queries), bot filtering on all metrics
- Stat cards exclude UNAFFILIATED, dormant excludes never-logged-in users
- Engagement status: never-logged=dormant, login<=7d+score>=10=active, 8-30d=at_risk
- Badge CSS: support both at-risk and at_risk class names
- Problems table: added Alerts and Locked columns
- Security alerts stat card in Problems tab
- Back link preserves tab/period context
- Trend chart Y-axis dynamic instead of hardcoded max:30
- Timeline truncation info when >= 150 events
- Migration 080: composite indexes on audit_logs and email_logs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 08:30:47 +01:00

750 lines
36 KiB
HTML

{% extends "base.html" %}
{% block title %}User Insights - Admin{% endblock %}
{% block extra_css %}
<style>
.insights-header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: var(--spacing-md); margin-bottom: var(--spacing-lg); }
.insights-header h1 { font-size: var(--font-size-2xl); font-weight: 700; }
.insights-controls { display: flex; gap: var(--spacing-sm); align-items: center; flex-wrap: wrap; }
/* Period tabs */
.period-tabs { display: flex; gap: var(--spacing-xs); background: white; padding: var(--spacing-xs); border-radius: var(--radius); box-shadow: var(--shadow-sm); }
.period-tab { padding: var(--spacing-sm) var(--spacing-md); border: none; background: transparent; border-radius: var(--radius-sm); cursor: pointer; font-size: var(--font-size-sm); color: var(--text-secondary); transition: var(--transition); text-decoration: none; }
.period-tab.active { background: var(--primary); color: white; font-weight: 500; }
.period-tab:hover:not(.active) { background: var(--background); }
/* Main tabs */
.tabs { display: flex; gap: var(--spacing-xs); margin-bottom: var(--spacing-lg); background: white; padding: var(--spacing-xs); border-radius: var(--radius); box-shadow: var(--shadow-sm); flex-wrap: wrap; }
.tab-link { padding: var(--spacing-sm) var(--spacing-md); border: none; background: transparent; border-radius: var(--radius-sm); cursor: pointer; font-size: var(--font-size-sm); font-weight: 500; color: var(--text-secondary); transition: var(--transition); text-decoration: none; white-space: nowrap; }
.tab-link.active { background: var(--primary); color: white; }
.tab-link:hover:not(.active) { background: var(--background); }
/* Stat cards */
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--spacing-md); margin-bottom: var(--spacing-xl); }
.stat-card { background: white; padding: var(--spacing-lg); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); }
.stat-card.error { border-left: 4px solid var(--error); }
.stat-card.warning { border-left: 4px solid #f59e0b; }
.stat-card.info { border-left: 4px solid #3b82f6; }
.stat-card.success { border-left: 4px solid var(--success); }
.stat-value { font-size: var(--font-size-2xl); font-weight: 700; color: var(--text-primary); }
.stat-label { color: var(--text-secondary); font-size: var(--font-size-sm); margin-top: var(--spacing-xs); }
/* Section cards */
.section-card { background: white; padding: var(--spacing-lg); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); margin-bottom: var(--spacing-xl); }
.section-card h2 { font-size: var(--font-size-lg); font-weight: 600; margin-bottom: var(--spacing-lg); display: flex; align-items: center; gap: var(--spacing-sm); }
/* Data table */
.data-table { width: 100%; border-collapse: collapse; }
.data-table th { text-align: left; padding: var(--spacing-sm) var(--spacing-md); background: var(--background); font-weight: 600; font-size: var(--font-size-xs); color: var(--text-secondary); border-bottom: 1px solid var(--border); text-transform: uppercase; letter-spacing: 0.5px; }
.data-table td { padding: var(--spacing-sm) var(--spacing-md); border-bottom: 1px solid var(--border); font-size: var(--font-size-sm); }
.data-table tr:last-child td { border-bottom: none; }
.data-table tr:hover td { background: var(--background); }
/* User cell */
.user-cell { display: flex; align-items: center; gap: var(--spacing-sm); }
.user-cell a { color: var(--primary); text-decoration: none; font-weight: 500; }
.user-cell a:hover { text-decoration: underline; }
.user-avatar { width: 32px; height: 32px; border-radius: 50%; background: var(--primary); color: white; display: flex; align-items: center; justify-content: center; font-size: var(--font-size-xs); font-weight: 600; flex-shrink: 0; }
/* Badges */
.badge { display: inline-block; padding: 2px 8px; border-radius: var(--radius); font-size: var(--font-size-xs); font-weight: 500; }
.badge-critical { background: #7f1d1d; color: white; }
.badge-high { background: #fee2e2; color: #991b1b; }
.badge-medium { background: #fef3c7; color: #92400e; }
.badge-low { background: #e0f2fe; color: #0369a1; }
.badge-ok { background: #dcfce7; color: #166534; }
.badge-active { background: #dcfce7; color: #166534; }
.badge-at-risk, .badge-at_risk { background: #fef3c7; color: #92400e; }
.badge-dormant { background: #fee2e2; color: #991b1b; }
/* Sparkline */
.sparkline { display: inline-flex; align-items: flex-end; gap: 2px; height: 24px; }
.sparkline-bar { width: 6px; background: var(--primary); border-radius: 1px; min-height: 2px; opacity: 0.7; }
/* WoW arrow */
.wow-up { color: #16a34a; font-weight: 600; }
.wow-down { color: #dc2626; font-weight: 600; }
.wow-flat { color: var(--text-muted); }
/* Horizontal bars */
.bar-container { height: 20px; background: var(--background); border-radius: var(--radius-sm); overflow: hidden; min-width: 100px; }
.bar-fill { height: 100%; border-radius: var(--radius-sm); transition: width 0.3s ease; }
.bar-fill.primary { background: var(--primary); }
.bar-fill.green { background: #22c55e; }
.bar-fill.blue { background: #3b82f6; }
/* Section heatmap grid */
.sections-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: var(--spacing-md); margin-bottom: var(--spacing-xl); }
.section-tile { padding: var(--spacing-lg); border-radius: var(--radius); text-align: center; transition: transform 0.2s; }
.section-tile:hover { transform: translateY(-2px); }
.section-tile h3 { font-size: var(--font-size-sm); font-weight: 600; margin-bottom: var(--spacing-sm); }
.section-tile .metric { font-size: var(--font-size-xs); color: rgba(0,0,0,0.6); }
/* Hourly heatmap */
.heatmap-table { border-collapse: collapse; width: 100%; }
.heatmap-table th { padding: 4px 6px; font-size: var(--font-size-xs); color: var(--text-secondary); font-weight: 500; }
.heatmap-cell { width: 28px; height: 28px; border-radius: 4px; margin: 1px; }
.heatmap-table td { padding: 1px; text-align: center; }
.heatmap-label { text-align: right; padding-right: 8px !important; font-weight: 500; font-size: var(--font-size-xs); color: var(--text-secondary); min-width: 30px; }
/* Two columns layout */
.two-columns { display: grid; grid-template-columns: 1fr 1fr; gap: var(--spacing-xl); }
@media (max-width: 1024px) { .two-columns { grid-template-columns: 1fr; } }
/* Path transitions */
.transition-row { display: flex; align-items: center; gap: var(--spacing-sm); padding: var(--spacing-sm) 0; border-bottom: 1px solid var(--border); font-size: var(--font-size-sm); }
.transition-arrow { color: var(--text-muted); }
.transition-count { font-weight: 600; min-width: 40px; text-align: right; }
/* Session length bars */
.bar-chart-row { display: flex; align-items: center; gap: var(--spacing-md); margin-bottom: var(--spacing-sm); }
.bar-chart-label { width: 100px; min-width: 100px; font-size: var(--font-size-sm); color: var(--text-secondary); }
.bar-chart-bar { flex: 1; height: 28px; background: var(--background); border-radius: var(--radius-sm); overflow: hidden; }
.bar-chart-fill { height: 100%; background: var(--primary); border-radius: var(--radius-sm); display: flex; align-items: center; padding-left: var(--spacing-sm); }
.bar-chart-fill span { color: white; font-size: var(--font-size-xs); font-weight: 600; }
/* Overview charts */
.chart-container { position: relative; height: 300px; }
/* Filter buttons */
.filter-group { display: flex; gap: var(--spacing-xs); }
.filter-btn { padding: 4px 12px; border: 1px solid var(--border); background: white; border-radius: var(--radius-sm); font-size: var(--font-size-xs); cursor: pointer; text-decoration: none; color: var(--text-secondary); }
.filter-btn.active { background: var(--primary); color: white; border-color: var(--primary); }
/* Export button */
.btn-export { display: inline-flex; align-items: center; gap: var(--spacing-xs); padding: var(--spacing-sm) var(--spacing-md); background: white; border: 1px solid var(--border); border-radius: var(--radius); font-size: var(--font-size-sm); color: var(--text-secondary); text-decoration: none; cursor: pointer; }
.btn-export:hover { background: var(--background); }
/* Table scroll */
.table-scroll { max-height: 500px; overflow-y: auto; }
/* Alerts */
.alert-card { display: flex; align-items: flex-start; gap: var(--spacing-md); padding: var(--spacing-md) var(--spacing-lg); border-radius: var(--radius); margin-bottom: var(--spacing-sm); }
.alert-card.critical { background: #fef2f2; border-left: 4px solid #dc2626; }
.alert-card.high { background: #fffbeb; border-left: 4px solid #f59e0b; }
.alert-card.medium { background: #eff6ff; border-left: 4px solid #3b82f6; }
.alert-icon { width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; font-size: 14px; }
.alert-card.critical .alert-icon { background: #fee2e2; }
.alert-card.high .alert-icon { background: #fef3c7; }
.alert-card.medium .alert-icon { background: #dbeafe; }
.alert-body { flex: 1; }
.alert-message { font-size: var(--font-size-sm); font-weight: 600; color: var(--text-primary); }
.alert-detail { font-size: var(--font-size-xs); color: var(--text-secondary); margin-top: 2px; }
.alert-action { flex-shrink: 0; }
.alert-action a { padding: 4px 12px; background: white; border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: var(--font-size-xs); color: var(--primary); text-decoration: none; }
.alert-action a:hover { background: var(--background); }
/* Responsive */
@media (max-width: 768px) {
.insights-header { flex-direction: column; align-items: flex-start; }
.tabs { overflow-x: auto; flex-wrap: nowrap; }
.sections-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
.data-table { font-size: var(--font-size-xs); }
.data-table th, .data-table td { padding: var(--spacing-xs) var(--spacing-sm); }
}
</style>
{% endblock %}
{% block content %}
<div class="admin-container" style="max-width: 1400px; margin: 0 auto; padding: var(--spacing-lg);">
<!-- Header -->
<div class="insights-header">
<h1>User Insights</h1>
<div class="insights-controls">
<div class="period-tabs">
<a href="{{ url_for('admin.user_insights', tab=tab, period='day') }}" class="period-tab {% if period == 'day' %}active{% endif %}">Dziś</a>
<a href="{{ url_for('admin.user_insights', tab=tab, period='week') }}" class="period-tab {% if period == 'week' %}active{% endif %}">7 dni</a>
<a href="{{ url_for('admin.user_insights', tab=tab, period='month') }}" class="period-tab {% if period == 'month' %}active{% endif %}">30 dni</a>
</div>
{% if tab in ['problems', 'engagement', 'pages'] %}
<a href="{{ url_for('admin.user_insights_export', type=tab, period=period) }}" class="btn-export">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>
CSV
</a>
{% endif %}
</div>
</div>
<!-- Tabs -->
<div class="tabs">
<a href="{{ url_for('admin.user_insights', tab='problems', period=period) }}" class="tab-link {% if tab == 'problems' %}active{% endif %}">Problemy</a>
<a href="{{ url_for('admin.user_insights', tab='engagement', period=period) }}" class="tab-link {% if tab == 'engagement' %}active{% endif %}">Zaangażowanie</a>
<a href="{{ url_for('admin.user_insights', tab='pages', period=period) }}" class="tab-link {% if tab == 'pages' %}active{% endif %}">Mapa stron</a>
<a href="{{ url_for('admin.user_insights', tab='paths', period=period) }}" class="tab-link {% if tab == 'paths' %}active{% endif %}">Ścieżki</a>
<a href="{{ url_for('admin.user_insights', tab='overview', period=period) }}" class="tab-link {% if tab == 'overview' %}active{% endif %}">Przegląd</a>
</div>
<!-- ============================================================ -->
<!-- TAB: PROBLEMS -->
<!-- ============================================================ -->
{% if tab == 'problems' %}
<div class="stats-grid">
<div class="stat-card error">
<div class="stat-value">{{ data.locked_accounts }}</div>
<div class="stat-label">Zablokowane konta</div>
</div>
<div class="stat-card warning">
<div class="stat-value">{{ data.failed_logins }}</div>
<div class="stat-label">Nieudane logowania</div>
</div>
<div class="stat-card info">
<div class="stat-value">{{ data.password_resets }}</div>
<div class="stat-label">Resety hasła</div>
</div>
<div class="stat-card warning">
<div class="stat-value">{{ data.js_errors }}</div>
<div class="stat-label">Błędy JS</div>
</div>
<div class="stat-card warning">
<div class="stat-value">{{ data.security_alerts }}</div>
<div class="stat-label">Alerty bezpieczeństwa</div>
</div>
<div class="stat-card error">
<div class="stat-value">{{ data.never_logged_in }}</div>
<div class="stat-label">Nigdy nie zalogowani</div>
</div>
</div>
<!-- Proactive alerts -->
{% if data.alerts %}
<div class="section-card">
<h2>Alerty proaktywne
<span class="badge badge-high">{{ data.alerts|length }}</span>
</h2>
{% for alert in data.alerts %}
<div class="alert-card {{ alert.priority }}">
<div class="alert-icon">
{% if alert.type == 'never_logged_in' %}👤{% elif alert.type == 'locked' %}🔒{% elif alert.type == 'reset_no_effect' %}📧{% elif alert.type == 'repeat_resets' %}🔄{% else %}⚠{% endif %}
</div>
<div class="alert-body">
<div class="alert-message">{{ alert.user.name or alert.user.email }}: {{ alert.message }}</div>
<div class="alert-detail">{{ alert.detail }}</div>
</div>
<div class="alert-action">
<a href="{{ url_for('admin.user_insights_profile', user_id=alert.user.id, ref_tab=tab, ref_period=period) }}">Szczegóły →</a>
</div>
</div>
{% endfor %}
</div>
{% endif %}
<div class="section-card">
<h2>Użytkownicy z problemami
<span class="badge {% if data.problem_users|length > 10 %}badge-high{% elif data.problem_users|length > 0 %}badge-medium{% else %}badge-ok{% endif %}">
{{ data.problem_users|length }}
</span>
</h2>
{% if data.problem_users %}
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th>Użytkownik</th>
<th>Problem Score</th>
<th>Nieudane logowania</th>
<th>Resety hasła</th>
<th>Alerty</th>
<th>Błędy JS</th>
<th>Wolne strony</th>
<th>🔒</th>
<th>Ostatni login</th>
</tr>
</thead>
<tbody>
{% for p in data.problem_users %}
<tr>
<td>
<div class="user-cell">
<div class="user-avatar">{{ p.user.name[0] if p.user.name else '?' }}</div>
<a href="{{ url_for('admin.user_insights_profile', user_id=p.user.id, ref_tab=tab, ref_period=period) }}">{{ p.user.name or p.user.email }}</a>
</div>
</td>
<td>
<span class="badge {% if p.score >= 51 %}badge-critical{% elif p.score >= 21 %}badge-high{% elif p.score >= 1 %}badge-medium{% else %}badge-ok{% endif %}">
{{ p.score }}
</span>
</td>
<td>{{ p.failed_logins }}</td>
<td>{{ p.password_resets }}</td>
<td>{{ p.security_alerts }}</td>
<td>{{ p.js_errors }}</td>
<td>{{ p.slow_pages }}</td>
<td>{% if p.is_locked %}<span class="badge badge-critical">Tak</span>{% endif %}</td>
<td>{{ p.last_login.strftime('%d.%m %H:%M') if p.last_login else 'Nigdy' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="text-align: center; padding: var(--spacing-xl); color: var(--text-muted);">
<p>Brak użytkowników z problemami w wybranym okresie.</p>
</div>
{% endif %}
</div>
<!-- ============================================================ -->
<!-- TAB: ENGAGEMENT -->
<!-- ============================================================ -->
{% elif tab == 'engagement' %}
<div class="stats-grid">
<div class="stat-card success">
<div class="stat-value">{{ data.active_7d }}</div>
<div class="stat-label">Aktywni ({{ period }})</div>
</div>
<div class="stat-card warning">
<div class="stat-value">{{ data.at_risk }}</div>
<div class="stat-label">Zagrożeni (8-30d)</div>
</div>
<div class="stat-card error">
<div class="stat-value">{{ data.dormant }}</div>
<div class="stat-label">Uśpieni (30d+)</div>
</div>
<div class="stat-card info">
<div class="stat-value">{{ data.new_this_month }}</div>
<div class="stat-label">Nowi (ten miesiąc)</div>
</div>
</div>
<div class="section-card">
<h2>Ranking zaangażowania</h2>
{% if data.engagement_list %}
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>Użytkownik</th>
<th>Score</th>
<th>Sesje</th>
<th>Odsłony</th>
<th>Zmiana</th>
<th>Aktywność</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for e in data.engagement_list %}
<tr>
<td style="font-weight: 600; color: var(--text-muted);">{{ loop.index }}</td>
<td>
<div class="user-cell">
<div class="user-avatar">{{ e.user.name[0] if e.user.name else '?' }}</div>
<a href="{{ url_for('admin.user_insights_profile', user_id=e.user.id, ref_tab=tab, ref_period=period) }}">{{ e.user.name or e.user.email }}</a>
</div>
</td>
<td><strong>{{ e.score }}</strong></td>
<td>{{ e.sessions }}</td>
<td>{{ e.page_views }}</td>
<td>
{% if e.wow is not none %}
{% if e.wow > 0 %}
<span class="wow-up">▲ {{ e.wow }}%</span>
{% elif e.wow < 0 %}
<span class="wow-down">▼ {{ e.wow|abs }}%</span>
{% else %}
<span class="wow-flat">— 0%</span>
{% endif %}
{% else %}
<span class="wow-flat">N/A</span>
{% endif %}
</td>
<td>
<div class="sparkline">
{% set max_spark = e.sparkline|max if e.sparkline|max > 0 else 1 %}
{% for val in e.sparkline %}
<div class="sparkline-bar" style="height: {{ (val / max_spark * 22 + 2)|int }}px;"></div>
{% endfor %}
</div>
</td>
<td>
<span class="badge badge-{{ e.status }}">
{% if e.status == 'active' %}Aktywny{% elif e.status == 'at_risk' %}Zagrożony{% else %}Uśpiony{% endif %}
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="text-align: center; padding: var(--spacing-xl); color: var(--text-muted);">
<p>Brak danych o zaangażowaniu w wybranym okresie.</p>
</div>
{% endif %}
</div>
<!-- ============================================================ -->
<!-- TAB: PAGE MAP -->
<!-- ============================================================ -->
{% elif tab == 'pages' %}
<!-- Section heatmap -->
<div class="section-card">
<h2>Popularność sekcji</h2>
<div class="sections-grid">
{% for s in data.sections %}
<div class="section-tile" style="background: rgba(34, 197, 94, {{ s.intensity / 100 * 0.3 + 0.05 }});">
<h3>{{ s.name }}</h3>
<div class="stat-value" style="font-size: var(--font-size-lg);">{{ s.views }}</div>
<div class="metric">{{ s.unique_users }} unikalnych &middot; {{ s.avg_time }}s śr.</div>
</div>
{% endfor %}
</div>
</div>
<!-- Top pages -->
<div class="section-card">
<h2>Top 50 stron</h2>
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th>Ścieżka</th>
<th style="width: 200px;">Odsłony</th>
<th>Unikalni</th>
<th>Śr. czas</th>
<th>Śr. scroll</th>
<th>Śr. ładowanie</th>
</tr>
</thead>
<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>
<div style="display: flex; align-items: center; gap: var(--spacing-sm);">
<div class="bar-container" style="flex: 1;">
<div class="bar-fill primary" style="width: {{ p.bar_pct }}%;"></div>
</div>
<span style="font-weight: 600; min-width: 40px; text-align: right;">{{ p.views }}</span>
</div>
</td>
<td>{{ p.unique_users }}</td>
<td>{{ p.avg_time }}s</td>
<td>{{ p.avg_scroll }}%</td>
<td>
<span {% if p.avg_load > 3000 %}style="color: var(--error); font-weight: 600;"{% elif p.avg_load > 1500 %}style="color: #d97706;"{% endif %}>
{{ p.avg_load }}ms
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Ignored pages -->
{% if data.ignored_pages %}
<div class="section-card">
<h2>Nieużywane strony <span class="badge badge-low">< 5 odsłon/30d</span></h2>
<div style="display: flex; flex-wrap: wrap; gap: var(--spacing-sm);">
{% for p in data.ignored_pages %}
<span style="padding: 4px 10px; background: var(--background); border-radius: var(--radius-sm); font-size: var(--font-size-xs); font-family: monospace; color: var(--text-muted);">
{{ p.path }} ({{ p.views }})
</span>
{% endfor %}
</div>
</div>
{% endif %}
<!-- ============================================================ -->
<!-- TAB: PATHS -->
<!-- ============================================================ -->
{% elif tab == 'paths' %}
<div class="two-columns">
<!-- Entry pages -->
<div class="section-card">
<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-bar">
<div class="bar-chart-fill" style="width: {{ p.bar_pct }}%; background: #22c55e;">
<span>{{ p.count }}</span>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Exit pages -->
<div class="section-card">
<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-bar">
<div class="bar-chart-fill" style="width: {{ p.bar_pct }}%; background: #ef4444;">
<span>{{ p.count }}</span>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<!-- Top transitions -->
<div class="section-card">
<h2>Popularne przejścia (top 30)</h2>
<div class="table-scroll" style="max-height: 400px;">
<table class="data-table">
<thead>
<tr>
<th>Ze strony</th>
<th style="width: 40px;"></th>
<th>Na stronę</th>
<th style="width: 80px;">Liczba</th>
</tr>
</thead>
<tbody>
{% for t in data.transitions %}
<tr>
<td style="font-family: monospace; font-size: var(--font-size-xs);">{{ t.from }}</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-weight: 600; text-align: right;">{{ t.count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="two-columns">
<!-- Drop-off pages -->
<div class="section-card">
<h2>Strony z odpływem</h2>
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th>Ścieżka</th>
<th>Odsłony</th>
<th>Wyjścia</th>
<th>Exit rate</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>{{ d.views }}</td>
<td>{{ d.exits }}</td>
<td>
<span {% if d.exit_rate > 70 %}style="color: var(--error); font-weight: 600;"{% elif d.exit_rate > 50 %}style="color: #d97706;"{% endif %}>
{{ d.exit_rate }}%
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Session length distribution -->
<div class="section-card">
<h2>Rozkład długości sesji</h2>
{% for s in data.session_lengths %}
<div class="bar-chart-row">
<div class="bar-chart-label">{{ s.bucket }}</div>
<div class="bar-chart-bar">
<div class="bar-chart-fill" style="width: {{ s.bar_pct }}%;">
<span>{{ s.count }}</span>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<!-- ============================================================ -->
<!-- TAB: OVERVIEW -->
<!-- ============================================================ -->
{% elif tab == 'overview' %}
<!-- Filter -->
<div style="margin-bottom: var(--spacing-lg);">
<div class="filter-group">
<a href="{{ url_for('admin.user_insights', tab='overview', period=period, filter='all') }}" class="filter-btn {% if data.filter_type == 'all' %}active{% endif %}">Wszyscy</a>
<a href="{{ url_for('admin.user_insights', tab='overview', period=period, filter='logged') }}" class="filter-btn {% if data.filter_type == 'logged' %}active{% endif %}">Zalogowani</a>
<a href="{{ url_for('admin.user_insights', tab='overview', period=period, filter='anonymous') }}" class="filter-btn {% if data.filter_type == 'anonymous' %}active{% endif %}">Anonimowi</a>
</div>
</div>
<!-- Sessions + Page Views chart -->
<div class="section-card">
<h2>Sesje i odsłony <small style="font-weight: 400; color: var(--text-muted); font-size: var(--font-size-sm);">(zawsze ostatnie 30 dni)</small></h2>
<div class="chart-container">
<canvas id="sessionsChart"></canvas>
</div>
</div>
<div class="two-columns">
<!-- Logged vs Anonymous doughnut -->
<div class="section-card">
<h2>Zalogowani vs Anonimowi</h2>
<div class="chart-container" style="height: 250px;">
<canvas id="authChart"></canvas>
</div>
</div>
<!-- Devices stacked bar -->
<div class="section-card">
<h2>Urządzenia (tygodniowo)</h2>
<div class="chart-container" style="height: 250px;">
<canvas id="devicesChart"></canvas>
</div>
</div>
</div>
<!-- Hourly heatmap -->
<div class="section-card">
<h2>Aktywność godzinowa (30 dni)</h2>
<div style="overflow-x: auto;">
<table class="heatmap-table">
<thead>
<tr>
<th></th>
{% for h in range(24) %}
<th>{{ h }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in data.heatmap %}
<tr>
<td class="heatmap-label">{{ row.name }}</td>
{% for cell in row.hours %}
<td title="{{ row.name }} {{ loop.index0 }}:00 — {{ cell.count }} sesji">
<div class="heatmap-cell" style="background: rgba(34, 197, 94, {{ cell.intensity / 100 * 0.8 + 0.05 }}); display: inline-block;"></div>
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
</div>
{% if tab == 'overview' %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
{% endif %}
{% endblock %}
{% block extra_js %}
{% if tab == 'overview' %}
// Sessions + Page Views line chart
const chartData = {{ data.chart_data|tojson|safe }};
const sessionsCtx = document.getElementById('sessionsChart').getContext('2d');
new Chart(sessionsCtx, {
type: 'line',
data: {
labels: chartData.labels,
datasets: [{
label: 'Sesje',
data: chartData.sessions,
borderColor: '#6366f1',
backgroundColor: 'rgba(99, 102, 241, 0.1)',
fill: true,
tension: 0.4,
yAxisID: 'y'
}, {
label: 'Odsłony',
data: chartData.pageviews,
borderColor: '#22c55e',
backgroundColor: 'rgba(34, 197, 94, 0.1)',
fill: true,
tension: 0.4,
yAxisID: 'y1'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: { legend: { position: 'top' } },
scales: {
x: { grid: { display: false } },
y: { type: 'linear', display: true, position: 'left', beginAtZero: true, title: { display: true, text: 'Sesje' } },
y1: { type: 'linear', display: true, position: 'right', beginAtZero: true, grid: { drawOnChartArea: false }, title: { display: true, text: 'Odsłony' } }
}
}
});
// Auth doughnut
const authData = {{ data.logged_vs_anon|tojson|safe }};
const authCtx = document.getElementById('authChart').getContext('2d');
new Chart(authCtx, {
type: 'doughnut',
data: {
labels: ['Zalogowani', 'Anonimowi'],
datasets: [{
data: [authData.logged, authData.anonymous],
backgroundColor: ['#6366f1', '#d1d5db'],
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom' },
tooltip: {
callbacks: {
label: function(ctx) {
const total = ctx.dataset.data.reduce((a, b) => a + b, 0);
const pct = total > 0 ? Math.round(ctx.raw / total * 100) : 0;
return ctx.label + ': ' + ctx.raw + ' (' + pct + '%)';
}
}
}
}
}
});
// Devices stacked bar
const devData = {{ data.devices|tojson|safe }};
const devCtx = document.getElementById('devicesChart').getContext('2d');
new Chart(devCtx, {
type: 'bar',
data: {
labels: devData.labels,
datasets: [{
label: 'Desktop',
data: devData.desktop,
backgroundColor: '#6366f1'
}, {
label: 'Mobile',
data: devData.mobile,
backgroundColor: '#22c55e'
}, {
label: 'Tablet',
data: devData.tablet,
backgroundColor: '#f59e0b'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: 'top' } },
scales: {
x: { stacked: true, grid: { display: false } },
y: { stacked: true, beginAtZero: true }
}
}
});
{% endif %}
{% endblock %}