feat: add User Insights dashboard with 5 tabs and user profiles
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
New admin dashboard at /admin/user-insights providing: - Problem detection tab (problem scoring, locked accounts, failed logins) - Engagement ranking tab (engagement scoring, WoW comparison, sparklines) - Page map tab (section heatmap, top 50 pages, unused pages) - Paths tab (entry/exit pages, transitions, drop-off analysis) - Overview tab (Chart.js charts, hourly heatmap, device breakdown) - Individual user drill-down profiles with timelines and gauges Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b187b0fc4a
commit
c3ecd86a8c
@ -33,3 +33,4 @@ from . import routes_data_quality # noqa: E402, F401
|
||||
from . import routes_bulk_enrichment # noqa: E402, F401
|
||||
from . import routes_website_discovery # noqa: E402, F401
|
||||
from . import routes_portal_seo # noqa: E402, F401
|
||||
from . import routes_user_insights # noqa: E402, F401
|
||||
|
||||
993
blueprints/admin/routes_user_insights.py
Normal file
993
blueprints/admin/routes_user_insights.py
Normal file
@ -0,0 +1,993 @@
|
||||
"""
|
||||
Admin User Insights Routes
|
||||
============================
|
||||
|
||||
User Insights Dashboard - problem detection, engagement scoring,
|
||||
page popularity, user flows, and behavioral profiles.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
from datetime import date, timedelta, datetime
|
||||
|
||||
from flask import render_template, request, redirect, url_for, flash, Response
|
||||
from flask_login import login_required
|
||||
from sqlalchemy import func, desc, text, or_
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from . import bp
|
||||
from database import (
|
||||
SessionLocal, User, UserSession, PageView, SearchQuery,
|
||||
ConversionEvent, JSError, EmailLog, SecurityAlert,
|
||||
AnalyticsDaily, SystemRole
|
||||
)
|
||||
from utils.decorators import role_required
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_period_dates(period):
|
||||
"""Return (start_date, days) for given period string."""
|
||||
today = date.today()
|
||||
if period == 'day':
|
||||
return today, 1
|
||||
elif period == 'month':
|
||||
return today - timedelta(days=30), 30
|
||||
else: # week (default)
|
||||
return today - timedelta(days=7), 7
|
||||
|
||||
|
||||
# ============================================================
|
||||
# MAIN DASHBOARD
|
||||
# ============================================================
|
||||
|
||||
@bp.route('/user-insights')
|
||||
@login_required
|
||||
@role_required(SystemRole.OFFICE_MANAGER)
|
||||
def user_insights():
|
||||
"""User Insights Dashboard - 5 tabs."""
|
||||
tab = request.args.get('tab', 'problems')
|
||||
period = request.args.get('period', 'week')
|
||||
start_date, days = _get_period_dates(period)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
data = {}
|
||||
|
||||
if tab == 'problems':
|
||||
data = _tab_problems(db, start_date, days)
|
||||
elif tab == 'engagement':
|
||||
data = _tab_engagement(db, start_date, days)
|
||||
elif tab == 'pages':
|
||||
data = _tab_pages(db, start_date, days)
|
||||
elif tab == 'paths':
|
||||
data = _tab_paths(db, start_date, days)
|
||||
elif tab == 'overview':
|
||||
data = _tab_overview(db, start_date, days)
|
||||
|
||||
return render_template(
|
||||
'admin/user_insights.html',
|
||||
tab=tab,
|
||||
period=period,
|
||||
data=data
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"User insights error: {e}", exc_info=True)
|
||||
flash('Błąd ładowania danych insights.', 'error')
|
||||
return redirect(url_for('admin.admin_analytics'))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# TAB 1: PROBLEMS
|
||||
# ============================================================
|
||||
|
||||
def _tab_problems(db, start_date, days):
|
||||
"""Problem detection tab - identify users with issues."""
|
||||
now = datetime.now()
|
||||
start_dt = datetime.combine(start_date, datetime.min.time())
|
||||
start_30d = datetime.combine(date.today() - timedelta(days=30), datetime.min.time())
|
||||
|
||||
# Stat cards
|
||||
locked_accounts = db.query(func.count(User.id)).filter(
|
||||
User.locked_until > now, User.is_active == True
|
||||
).scalar() or 0
|
||||
|
||||
failed_logins_7d = db.query(func.count(SecurityAlert.id)).filter(
|
||||
SecurityAlert.alert_type.in_(['brute_force', 'account_locked']),
|
||||
SecurityAlert.created_at >= start_dt
|
||||
).scalar() or 0
|
||||
|
||||
password_resets_7d = db.query(func.count(EmailLog.id)).filter(
|
||||
EmailLog.email_type == 'password_reset',
|
||||
EmailLog.created_at >= start_dt
|
||||
).scalar() or 0
|
||||
|
||||
js_errors_7d = db.query(func.count(JSError.id)).filter(
|
||||
JSError.occurred_at >= start_dt
|
||||
).scalar() or 0
|
||||
|
||||
# Problem users - raw data per user
|
||||
users = db.query(User).filter(User.is_active == True).all()
|
||||
problem_users = []
|
||||
|
||||
for user in users:
|
||||
# Failed logins
|
||||
fl = user.failed_login_attempts or 0
|
||||
|
||||
# Security alerts 7d
|
||||
sa_7d = db.query(func.count(SecurityAlert.id)).filter(
|
||||
SecurityAlert.user_email == user.email,
|
||||
SecurityAlert.created_at >= start_dt
|
||||
).scalar() or 0
|
||||
|
||||
# Password resets 30d
|
||||
pr_30d = db.query(func.count(EmailLog.id)).filter(
|
||||
EmailLog.user_id == user.id,
|
||||
EmailLog.email_type == 'password_reset',
|
||||
EmailLog.created_at >= start_30d
|
||||
).scalar() or 0
|
||||
|
||||
# JS errors 7d (via sessions)
|
||||
je_7d = db.query(func.count(JSError.id)).join(
|
||||
UserSession, JSError.session_id == UserSession.id
|
||||
).filter(
|
||||
UserSession.user_id == user.id,
|
||||
JSError.occurred_at >= start_dt
|
||||
).scalar() or 0
|
||||
|
||||
# Slow pages 7d
|
||||
sp_7d = db.query(func.count(PageView.id)).filter(
|
||||
PageView.user_id == user.id,
|
||||
PageView.viewed_at >= start_dt,
|
||||
PageView.load_time_ms > 3000
|
||||
).scalar() or 0
|
||||
|
||||
is_locked = 1 if user.locked_until and user.locked_until > now else 0
|
||||
|
||||
score = min(100,
|
||||
fl * 10 +
|
||||
pr_30d * 15 +
|
||||
je_7d * 3 +
|
||||
sp_7d * 2 +
|
||||
sa_7d * 20 +
|
||||
is_locked * 40
|
||||
)
|
||||
|
||||
if score > 0:
|
||||
problem_users.append({
|
||||
'user': user,
|
||||
'score': score,
|
||||
'failed_logins': fl,
|
||||
'password_resets': pr_30d,
|
||||
'js_errors': je_7d,
|
||||
'slow_pages': sp_7d,
|
||||
'security_alerts': sa_7d,
|
||||
'is_locked': is_locked,
|
||||
'last_login': user.last_login,
|
||||
})
|
||||
|
||||
problem_users.sort(key=lambda x: x['score'], reverse=True)
|
||||
|
||||
return {
|
||||
'locked_accounts': locked_accounts,
|
||||
'failed_logins': failed_logins_7d,
|
||||
'password_resets': password_resets_7d,
|
||||
'js_errors': js_errors_7d,
|
||||
'problem_users': problem_users[:50],
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# TAB 2: ENGAGEMENT
|
||||
# ============================================================
|
||||
|
||||
def _tab_engagement(db, start_date, days):
|
||||
"""Engagement ranking tab."""
|
||||
now = datetime.now()
|
||||
start_dt = datetime.combine(start_date, datetime.min.time())
|
||||
start_30d = datetime.combine(date.today() - timedelta(days=30), datetime.min.time())
|
||||
prev_start = datetime.combine(start_date - timedelta(days=days), datetime.min.time())
|
||||
|
||||
# Stat cards
|
||||
active_7d = db.query(func.count(func.distinct(UserSession.user_id))).filter(
|
||||
UserSession.user_id.isnot(None),
|
||||
UserSession.started_at >= start_dt
|
||||
).scalar() or 0
|
||||
|
||||
all_users = db.query(User).filter(User.is_active == True).all()
|
||||
|
||||
at_risk = 0
|
||||
dormant = 0
|
||||
new_this_month = 0
|
||||
first_of_month = date.today().replace(day=1)
|
||||
|
||||
for u in all_users:
|
||||
if u.created_at and u.created_at.date() >= first_of_month:
|
||||
new_this_month += 1
|
||||
if u.last_login:
|
||||
days_since = (date.today() - u.last_login.date()).days
|
||||
if 8 <= days_since <= 30:
|
||||
at_risk += 1
|
||||
elif days_since > 30:
|
||||
dormant += 1
|
||||
elif u.last_login is None:
|
||||
dormant += 1
|
||||
|
||||
# Engagement ranking - compute per user
|
||||
registered_users = db.query(User).filter(
|
||||
User.is_active == True, User.role != 'UNAFFILIATED'
|
||||
).all()
|
||||
|
||||
engagement_list = []
|
||||
for user in registered_users:
|
||||
# Current period
|
||||
sessions_cur = db.query(func.count(UserSession.id)).filter(
|
||||
UserSession.user_id == user.id,
|
||||
UserSession.started_at >= start_dt
|
||||
).scalar() or 0
|
||||
|
||||
pv_cur = db.query(func.count(PageView.id)).filter(
|
||||
PageView.user_id == user.id,
|
||||
PageView.viewed_at >= start_dt
|
||||
).scalar() or 0
|
||||
|
||||
# Previous period for WoW
|
||||
sessions_prev = db.query(func.count(UserSession.id)).filter(
|
||||
UserSession.user_id == user.id,
|
||||
UserSession.started_at >= prev_start,
|
||||
UserSession.started_at < start_dt
|
||||
).scalar() or 0
|
||||
|
||||
pv_prev = db.query(func.count(PageView.id)).filter(
|
||||
PageView.user_id == user.id,
|
||||
PageView.viewed_at >= prev_start,
|
||||
PageView.viewed_at < start_dt
|
||||
).scalar() or 0
|
||||
|
||||
# 30d engagement score components
|
||||
s30 = db.query(func.count(UserSession.id)).filter(
|
||||
UserSession.user_id == user.id,
|
||||
UserSession.started_at >= start_30d
|
||||
).scalar() or 0
|
||||
|
||||
pv30 = db.query(func.count(PageView.id)).filter(
|
||||
PageView.user_id == user.id,
|
||||
PageView.viewed_at >= start_30d
|
||||
).scalar() or 0
|
||||
|
||||
clicks30 = db.query(func.sum(UserSession.clicks_count)).filter(
|
||||
UserSession.user_id == user.id,
|
||||
UserSession.started_at >= start_30d
|
||||
).scalar() or 0
|
||||
|
||||
dur30 = db.query(func.sum(UserSession.duration_seconds)).filter(
|
||||
UserSession.user_id == user.id,
|
||||
UserSession.started_at >= start_30d
|
||||
).scalar() or 0
|
||||
|
||||
conv30 = db.query(func.count(ConversionEvent.id)).filter(
|
||||
ConversionEvent.user_id == user.id,
|
||||
ConversionEvent.converted_at >= start_30d
|
||||
).scalar() or 0
|
||||
|
||||
search30 = db.query(func.count(SearchQuery.id)).filter(
|
||||
SearchQuery.user_id == user.id,
|
||||
SearchQuery.searched_at >= start_30d
|
||||
).scalar() or 0
|
||||
|
||||
score = min(100,
|
||||
s30 * 3 + pv30 * 1 + int(clicks30) * 0.5 +
|
||||
int(dur30) / 60 * 2 + conv30 * 10 + search30 * 2
|
||||
)
|
||||
score = int(score)
|
||||
|
||||
# WoW change
|
||||
wow = None
|
||||
if pv_prev > 0:
|
||||
wow = round((pv_cur - pv_prev) / pv_prev * 100)
|
||||
elif pv_cur > 0:
|
||||
wow = 100
|
||||
|
||||
# Status
|
||||
days_since_login = None
|
||||
if user.last_login:
|
||||
days_since_login = (date.today() - user.last_login.date()).days
|
||||
|
||||
if days_since_login is not None and days_since_login <= 7 and score >= 20:
|
||||
status = 'active'
|
||||
elif (days_since_login is not None and 8 <= days_since_login <= 30) or (5 <= score < 20):
|
||||
status = 'at_risk'
|
||||
else:
|
||||
status = 'dormant'
|
||||
|
||||
# Daily sparkline (7 days)
|
||||
sparkline = []
|
||||
for i in range(7):
|
||||
d = date.today() - timedelta(days=6 - i)
|
||||
d_start = datetime.combine(d, datetime.min.time())
|
||||
d_end = datetime.combine(d + timedelta(days=1), datetime.min.time())
|
||||
cnt = db.query(func.count(PageView.id)).filter(
|
||||
PageView.user_id == user.id,
|
||||
PageView.viewed_at >= d_start,
|
||||
PageView.viewed_at < d_end
|
||||
).scalar() or 0
|
||||
sparkline.append(cnt)
|
||||
|
||||
if sessions_cur > 0 or score > 0:
|
||||
engagement_list.append({
|
||||
'user': user,
|
||||
'score': score,
|
||||
'sessions': sessions_cur,
|
||||
'page_views': pv_cur,
|
||||
'wow': wow,
|
||||
'status': status,
|
||||
'sparkline': sparkline,
|
||||
})
|
||||
|
||||
engagement_list.sort(key=lambda x: x['score'], reverse=True)
|
||||
|
||||
return {
|
||||
'active_7d': active_7d,
|
||||
'at_risk': at_risk,
|
||||
'dormant': dormant,
|
||||
'new_this_month': new_this_month,
|
||||
'engagement_list': engagement_list[:50],
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# TAB 3: PAGE MAP
|
||||
# ============================================================
|
||||
|
||||
def _tab_pages(db, start_date, days):
|
||||
"""Page popularity map."""
|
||||
start_dt = datetime.combine(start_date, datetime.min.time())
|
||||
|
||||
# Page sections with grouping
|
||||
section_map = {
|
||||
'Strona główna': ['/'],
|
||||
'Profile firm': ['/company/'],
|
||||
'Forum': ['/forum'],
|
||||
'Chat': ['/chat'],
|
||||
'Wyszukiwarka': ['/search', '/szukaj'],
|
||||
'Wydarzenia': ['/events', '/wydarzenia'],
|
||||
'Ogłoszenia': ['/classifieds', '/ogloszenia'],
|
||||
'Członkostwo': ['/membership', '/czlonkostwo'],
|
||||
'Admin': ['/admin'],
|
||||
}
|
||||
|
||||
sections = []
|
||||
for name, prefixes in section_map.items():
|
||||
conditions = [PageView.path.like(p + '%') for p in prefixes]
|
||||
if prefixes == ['/']:
|
||||
conditions = [PageView.path == '/']
|
||||
|
||||
q = db.query(
|
||||
func.count(PageView.id).label('views'),
|
||||
func.count(func.distinct(PageView.user_id)).label('unique_users'),
|
||||
func.avg(PageView.time_on_page_seconds).label('avg_time')
|
||||
).filter(
|
||||
or_(*conditions),
|
||||
PageView.viewed_at >= start_dt
|
||||
).first()
|
||||
|
||||
sections.append({
|
||||
'name': name,
|
||||
'views': q.views or 0,
|
||||
'unique_users': q.unique_users or 0,
|
||||
'avg_time': int(q.avg_time or 0),
|
||||
})
|
||||
|
||||
max_views = max((s['views'] for s in sections), default=1) or 1
|
||||
|
||||
for s in sections:
|
||||
s['intensity'] = min(100, int(s['views'] / max_views * 100))
|
||||
|
||||
# Top 50 pages
|
||||
top_pages = db.query(
|
||||
PageView.path,
|
||||
func.count(PageView.id).label('views'),
|
||||
func.count(func.distinct(PageView.user_id)).label('unique_users'),
|
||||
func.avg(PageView.time_on_page_seconds).label('avg_time'),
|
||||
func.avg(PageView.scroll_depth_percent).label('avg_scroll'),
|
||||
func.avg(PageView.load_time_ms).label('avg_load'),
|
||||
).filter(
|
||||
PageView.viewed_at >= start_dt
|
||||
).group_by(PageView.path).order_by(desc('views')).limit(50).all()
|
||||
|
||||
max_page_views = top_pages[0].views if top_pages else 1
|
||||
|
||||
pages_list = []
|
||||
for p in top_pages:
|
||||
pages_list.append({
|
||||
'path': p.path,
|
||||
'views': p.views,
|
||||
'unique_users': p.unique_users,
|
||||
'avg_time': int(p.avg_time or 0),
|
||||
'avg_scroll': int(p.avg_scroll or 0),
|
||||
'avg_load': int(p.avg_load or 0),
|
||||
'bar_pct': int(p.views / max_page_views * 100),
|
||||
})
|
||||
|
||||
# Ignored pages (< 5 views in 30d)
|
||||
start_30d = datetime.combine(date.today() - timedelta(days=30), datetime.min.time())
|
||||
ignored = db.query(
|
||||
PageView.path,
|
||||
func.count(PageView.id).label('views'),
|
||||
).filter(
|
||||
PageView.viewed_at >= start_30d
|
||||
).group_by(PageView.path).having(
|
||||
func.count(PageView.id) < 5
|
||||
).order_by('views').limit(30).all()
|
||||
|
||||
return {
|
||||
'sections': sections,
|
||||
'top_pages': pages_list,
|
||||
'ignored_pages': [{'path': p.path, 'views': p.views} for p in ignored],
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# TAB 4: PATHS
|
||||
# ============================================================
|
||||
|
||||
def _tab_paths(db, start_date, days):
|
||||
"""User flow analysis."""
|
||||
start_dt = datetime.combine(start_date, datetime.min.time())
|
||||
|
||||
# Entry pages - first page in each session
|
||||
entry_sql = text("""
|
||||
WITH first_pages AS (
|
||||
SELECT DISTINCT ON (session_id) path
|
||||
FROM page_views
|
||||
WHERE viewed_at >= :start_dt AND session_id IS NOT NULL
|
||||
ORDER BY session_id, viewed_at ASC
|
||||
)
|
||||
SELECT path, COUNT(*) as cnt
|
||||
FROM first_pages
|
||||
GROUP BY path ORDER BY cnt DESC LIMIT 10
|
||||
""")
|
||||
entry_pages = db.execute(entry_sql, {'start_dt': start_dt}).fetchall()
|
||||
|
||||
# Exit pages - last page in each session
|
||||
exit_sql = text("""
|
||||
WITH last_pages AS (
|
||||
SELECT DISTINCT ON (session_id) path
|
||||
FROM page_views
|
||||
WHERE viewed_at >= :start_dt AND session_id IS NOT NULL
|
||||
ORDER BY session_id, viewed_at DESC
|
||||
)
|
||||
SELECT path, COUNT(*) as cnt
|
||||
FROM last_pages
|
||||
GROUP BY path ORDER BY cnt DESC LIMIT 10
|
||||
""")
|
||||
exit_pages = db.execute(exit_sql, {'start_dt': start_dt}).fetchall()
|
||||
|
||||
max_entry = entry_pages[0].cnt if entry_pages else 1
|
||||
max_exit = exit_pages[0].cnt if exit_pages else 1
|
||||
|
||||
# Top transitions
|
||||
transitions_sql = text("""
|
||||
WITH ordered AS (
|
||||
SELECT session_id, path,
|
||||
LEAD(path) OVER (PARTITION BY session_id ORDER BY viewed_at) AS next_path
|
||||
FROM page_views
|
||||
WHERE viewed_at >= :start_dt AND session_id IS NOT NULL
|
||||
)
|
||||
SELECT path, next_path, COUNT(*) as cnt
|
||||
FROM ordered
|
||||
WHERE next_path IS NOT NULL AND path != next_path
|
||||
GROUP BY path, next_path ORDER BY cnt DESC LIMIT 30
|
||||
""")
|
||||
transitions = db.execute(transitions_sql, {'start_dt': start_dt}).fetchall()
|
||||
|
||||
# Drop-off pages (high exit rate)
|
||||
dropoff_sql = text("""
|
||||
WITH page_stats AS (
|
||||
SELECT path, COUNT(*) as total_views
|
||||
FROM page_views
|
||||
WHERE viewed_at >= :start_dt AND session_id IS NOT NULL
|
||||
GROUP BY path HAVING COUNT(*) >= 5
|
||||
),
|
||||
exit_stats AS (
|
||||
SELECT path, COUNT(*) as exit_count
|
||||
FROM (
|
||||
SELECT DISTINCT ON (session_id) path
|
||||
FROM page_views
|
||||
WHERE viewed_at >= :start_dt AND session_id IS NOT NULL
|
||||
ORDER BY session_id, viewed_at DESC
|
||||
) lp
|
||||
GROUP BY path
|
||||
)
|
||||
SELECT ps.path, ps.total_views as views,
|
||||
COALESCE(es.exit_count, 0) as exits,
|
||||
ROUND(COALESCE(es.exit_count, 0)::numeric / ps.total_views * 100, 1) as exit_rate
|
||||
FROM page_stats ps
|
||||
LEFT JOIN exit_stats es ON ps.path = es.path
|
||||
ORDER BY exit_rate DESC LIMIT 20
|
||||
""")
|
||||
dropoff = db.execute(dropoff_sql, {'start_dt': start_dt}).fetchall()
|
||||
|
||||
# Session length distribution
|
||||
session_length_sql = text("""
|
||||
SELECT
|
||||
CASE
|
||||
WHEN pv_count = 1 THEN '1 strona'
|
||||
WHEN pv_count = 2 THEN '2 strony'
|
||||
WHEN pv_count BETWEEN 3 AND 5 THEN '3-5 stron'
|
||||
WHEN pv_count BETWEEN 6 AND 10 THEN '6-10 stron'
|
||||
ELSE '10+ stron'
|
||||
END as bucket,
|
||||
COUNT(*) as cnt
|
||||
FROM (
|
||||
SELECT session_id, COUNT(*) as pv_count
|
||||
FROM page_views
|
||||
WHERE viewed_at >= :start_dt AND session_id IS NOT NULL
|
||||
GROUP BY session_id
|
||||
) session_counts
|
||||
GROUP BY bucket
|
||||
ORDER BY MIN(pv_count)
|
||||
""")
|
||||
session_lengths = db.execute(session_length_sql, {'start_dt': start_dt}).fetchall()
|
||||
max_sl = max((r.cnt for r in session_lengths), default=1) or 1
|
||||
|
||||
return {
|
||||
'entry_pages': [{'path': r.path, 'count': r.cnt, 'bar_pct': int(r.cnt / max_entry * 100)} for r in entry_pages],
|
||||
'exit_pages': [{'path': r.path, 'count': r.cnt, 'bar_pct': int(r.cnt / max_exit * 100)} for r in exit_pages],
|
||||
'transitions': [{'from': r.path, 'to': r.next_path, 'count': r.cnt} for r in transitions],
|
||||
'dropoff': [{'path': r.path, 'views': r.views, 'exits': r.exits, 'exit_rate': float(r.exit_rate)} for r in dropoff],
|
||||
'session_lengths': [{'bucket': r.bucket, 'count': r.cnt, 'bar_pct': int(r.cnt / max_sl * 100)} for r in session_lengths],
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# TAB 5: OVERVIEW
|
||||
# ============================================================
|
||||
|
||||
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())
|
||||
|
||||
# Daily sessions + page views (30d)
|
||||
daily_data = db.query(AnalyticsDaily).filter(
|
||||
AnalyticsDaily.date >= date.today() - timedelta(days=30)
|
||||
).order_by(AnalyticsDaily.date).all()
|
||||
|
||||
chart_labels = []
|
||||
chart_sessions = []
|
||||
chart_pageviews = []
|
||||
for d in daily_data:
|
||||
chart_labels.append(d.date.strftime('%d.%m'))
|
||||
if filter_type == 'logged':
|
||||
chart_sessions.append(d.total_sessions - (d.anonymous_sessions or 0))
|
||||
elif filter_type == 'anonymous':
|
||||
chart_sessions.append(d.anonymous_sessions or 0)
|
||||
else:
|
||||
chart_sessions.append(d.total_sessions or 0)
|
||||
chart_pageviews.append(d.total_page_views or 0)
|
||||
|
||||
# Hourly heatmap (7 days x 24 hours)
|
||||
heatmap_sql = text("""
|
||||
SELECT EXTRACT(DOW FROM started_at)::int as dow,
|
||||
EXTRACT(HOUR FROM started_at)::int as hour,
|
||||
COUNT(*) as cnt
|
||||
FROM user_sessions
|
||||
WHERE started_at >= :start_dt
|
||||
GROUP BY dow, hour
|
||||
""")
|
||||
heatmap_raw = db.execute(heatmap_sql, {'start_dt': start_30d}).fetchall()
|
||||
heatmap = {}
|
||||
max_heat = 1
|
||||
for r in heatmap_raw:
|
||||
key = (r.dow, r.hour)
|
||||
heatmap[key] = r.cnt
|
||||
if r.cnt > max_heat:
|
||||
max_heat = r.cnt
|
||||
|
||||
heatmap_grid = []
|
||||
dow_names = ['Nd', 'Pn', 'Wt', 'Śr', 'Cz', 'Pt', 'Sb']
|
||||
for dow in range(7):
|
||||
row = {'name': dow_names[dow], 'hours': []}
|
||||
for h in range(24):
|
||||
cnt = heatmap.get((dow, h), 0)
|
||||
intensity = int(cnt / max_heat * 100) if max_heat else 0
|
||||
row['hours'].append({'count': cnt, 'intensity': intensity})
|
||||
heatmap_grid.append(row)
|
||||
|
||||
# Logged vs Anonymous
|
||||
total_logged = db.query(func.count(UserSession.id)).filter(
|
||||
UserSession.started_at >= start_30d,
|
||||
UserSession.user_id.isnot(None)
|
||||
).scalar() or 0
|
||||
total_anon = db.query(func.count(UserSession.id)).filter(
|
||||
UserSession.started_at >= start_30d,
|
||||
UserSession.user_id.is_(None)
|
||||
).scalar() or 0
|
||||
|
||||
# Devices over time (weekly)
|
||||
devices_sql = text("""
|
||||
SELECT DATE_TRUNC('week', started_at)::date as week,
|
||||
device_type,
|
||||
COUNT(*) as cnt
|
||||
FROM user_sessions
|
||||
WHERE started_at >= :start_dt
|
||||
GROUP BY week, device_type
|
||||
ORDER BY week
|
||||
""")
|
||||
devices_raw = db.execute(devices_sql, {'start_dt': start_30d}).fetchall()
|
||||
weeks_set = sorted(set(r.week for r in devices_raw))
|
||||
device_map = {}
|
||||
for r in devices_raw:
|
||||
if r.week not in device_map:
|
||||
device_map[r.week] = {}
|
||||
device_map[r.week][r.device_type or 'unknown'] = r.cnt
|
||||
|
||||
device_labels = [w.strftime('%d.%m') for w in weeks_set]
|
||||
device_desktop = [device_map.get(w, {}).get('desktop', 0) for w in weeks_set]
|
||||
device_mobile = [device_map.get(w, {}).get('mobile', 0) for w in weeks_set]
|
||||
device_tablet = [device_map.get(w, {}).get('tablet', 0) for w in weeks_set]
|
||||
|
||||
return {
|
||||
'filter_type': filter_type,
|
||||
'chart_data': {
|
||||
'labels': chart_labels,
|
||||
'sessions': chart_sessions,
|
||||
'pageviews': chart_pageviews,
|
||||
},
|
||||
'heatmap': heatmap_grid,
|
||||
'logged_vs_anon': {'logged': total_logged, 'anonymous': total_anon},
|
||||
'devices': {
|
||||
'labels': device_labels,
|
||||
'desktop': device_desktop,
|
||||
'mobile': device_mobile,
|
||||
'tablet': device_tablet,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# USER PROFILE DRILL-DOWN
|
||||
# ============================================================
|
||||
|
||||
@bp.route('/user-insights/user/<int:user_id>')
|
||||
@login_required
|
||||
@role_required(SystemRole.OFFICE_MANAGER)
|
||||
def user_insights_profile(user_id):
|
||||
"""Individual user behavioral profile."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = db.query(User).options(joinedload(User.company)).get(user_id)
|
||||
if not user:
|
||||
flash('Użytkownik nie znaleziony.', 'error')
|
||||
return redirect(url_for('admin.user_insights'))
|
||||
|
||||
now = datetime.now()
|
||||
start_30d = datetime.combine(date.today() - timedelta(days=30), datetime.min.time())
|
||||
start_7d = datetime.combine(date.today() - timedelta(days=7), datetime.min.time())
|
||||
|
||||
# Engagement score (30d)
|
||||
s30 = db.query(func.count(UserSession.id)).filter(
|
||||
UserSession.user_id == user_id, UserSession.started_at >= start_30d
|
||||
).scalar() or 0
|
||||
|
||||
pv30 = db.query(func.count(PageView.id)).filter(
|
||||
PageView.user_id == user_id, PageView.viewed_at >= start_30d
|
||||
).scalar() or 0
|
||||
|
||||
clicks30 = db.query(func.sum(UserSession.clicks_count)).filter(
|
||||
UserSession.user_id == user_id, UserSession.started_at >= start_30d
|
||||
).scalar() or 0
|
||||
|
||||
dur30 = db.query(func.sum(UserSession.duration_seconds)).filter(
|
||||
UserSession.user_id == user_id, UserSession.started_at >= start_30d
|
||||
).scalar() or 0
|
||||
|
||||
conv30 = db.query(func.count(ConversionEvent.id)).filter(
|
||||
ConversionEvent.user_id == user_id, ConversionEvent.converted_at >= start_30d
|
||||
).scalar() or 0
|
||||
|
||||
search30 = db.query(func.count(SearchQuery.id)).filter(
|
||||
SearchQuery.user_id == user_id, SearchQuery.searched_at >= start_30d
|
||||
).scalar() or 0
|
||||
|
||||
engagement_score = min(100, int(
|
||||
s30 * 3 + pv30 * 1 + int(clicks30) * 0.5 +
|
||||
int(dur30) / 60 * 2 + conv30 * 10 + search30 * 2
|
||||
))
|
||||
|
||||
# Problem score
|
||||
fl = user.failed_login_attempts or 0
|
||||
sa_7d = db.query(func.count(SecurityAlert.id)).filter(
|
||||
SecurityAlert.user_email == user.email,
|
||||
SecurityAlert.created_at >= start_7d
|
||||
).scalar() or 0
|
||||
pr_30d = db.query(func.count(EmailLog.id)).filter(
|
||||
EmailLog.user_id == user_id,
|
||||
EmailLog.email_type == 'password_reset',
|
||||
EmailLog.created_at >= start_30d
|
||||
).scalar() or 0
|
||||
je_7d = db.query(func.count(JSError.id)).join(
|
||||
UserSession, JSError.session_id == UserSession.id
|
||||
).filter(
|
||||
UserSession.user_id == user_id,
|
||||
JSError.occurred_at >= start_7d
|
||||
).scalar() or 0
|
||||
sp_7d = db.query(func.count(PageView.id)).filter(
|
||||
PageView.user_id == user_id,
|
||||
PageView.viewed_at >= start_7d,
|
||||
PageView.load_time_ms > 3000
|
||||
).scalar() or 0
|
||||
is_locked = 1 if user.locked_until and user.locked_until > now else 0
|
||||
|
||||
problem_score = min(100,
|
||||
fl * 10 + pr_30d * 15 + je_7d * 3 + sp_7d * 2 + sa_7d * 20 + is_locked * 40
|
||||
)
|
||||
|
||||
# Timeline (last 100 events)
|
||||
timeline = []
|
||||
|
||||
# Recent sessions (logins)
|
||||
sessions = db.query(UserSession).filter(
|
||||
UserSession.user_id == user_id
|
||||
).order_by(desc(UserSession.started_at)).limit(20).all()
|
||||
for s in sessions:
|
||||
timeline.append({
|
||||
'type': 'login',
|
||||
'icon': 'key',
|
||||
'time': s.started_at,
|
||||
'desc': f'Sesja ({s.device_type or "?"}, {s.browser or "?"})',
|
||||
})
|
||||
|
||||
# Recent page views (key pages only)
|
||||
key_paths = ['/', '/forum', '/chat', '/search', '/admin', '/events', '/membership']
|
||||
recent_pvs = db.query(PageView).filter(
|
||||
PageView.user_id == user_id,
|
||||
).order_by(desc(PageView.viewed_at)).limit(50).all()
|
||||
for pv in recent_pvs:
|
||||
is_key = any(pv.path == p or pv.path.startswith(p + '/') for p in key_paths)
|
||||
if is_key or '/company/' in pv.path:
|
||||
timeline.append({
|
||||
'type': 'pageview',
|
||||
'icon': 'eye',
|
||||
'time': pv.viewed_at,
|
||||
'desc': f'Odwiedzono: {pv.path}',
|
||||
})
|
||||
|
||||
# Recent searches
|
||||
searches = db.query(SearchQuery).filter(
|
||||
SearchQuery.user_id == user_id
|
||||
).order_by(desc(SearchQuery.searched_at)).limit(10).all()
|
||||
for s in searches:
|
||||
timeline.append({
|
||||
'type': 'search',
|
||||
'icon': 'search',
|
||||
'time': s.searched_at,
|
||||
'desc': f'Szukano: "{s.query}"',
|
||||
})
|
||||
|
||||
# Conversions
|
||||
convs = db.query(ConversionEvent).filter(
|
||||
ConversionEvent.user_id == user_id
|
||||
).order_by(desc(ConversionEvent.converted_at)).limit(10).all()
|
||||
for c in convs:
|
||||
timeline.append({
|
||||
'type': 'conversion',
|
||||
'icon': 'check',
|
||||
'time': c.converted_at,
|
||||
'desc': f'Konwersja: {c.event_type}',
|
||||
})
|
||||
|
||||
# Password resets
|
||||
resets = db.query(EmailLog).filter(
|
||||
EmailLog.user_id == user_id,
|
||||
EmailLog.email_type == 'password_reset'
|
||||
).order_by(desc(EmailLog.created_at)).limit(5).all()
|
||||
for r in resets:
|
||||
timeline.append({
|
||||
'type': 'problem',
|
||||
'icon': 'alert',
|
||||
'time': r.created_at,
|
||||
'desc': 'Reset hasła',
|
||||
})
|
||||
|
||||
# Security alerts
|
||||
alerts = db.query(SecurityAlert).filter(
|
||||
SecurityAlert.user_email == user.email
|
||||
).order_by(desc(SecurityAlert.created_at)).limit(10).all()
|
||||
for a in alerts:
|
||||
timeline.append({
|
||||
'type': 'problem',
|
||||
'icon': 'shield',
|
||||
'time': a.created_at,
|
||||
'desc': f'Alert: {a.alert_type} ({a.severity})',
|
||||
})
|
||||
|
||||
timeline.sort(key=lambda x: x['time'], reverse=True)
|
||||
timeline = timeline[:100]
|
||||
|
||||
# Favorite pages (top 10)
|
||||
fav_pages = db.query(
|
||||
PageView.path,
|
||||
func.count(PageView.id).label('cnt')
|
||||
).filter(
|
||||
PageView.user_id == user_id,
|
||||
PageView.viewed_at >= start_30d
|
||||
).group_by(PageView.path).order_by(desc('cnt')).limit(10).all()
|
||||
|
||||
max_fav = fav_pages[0].cnt if fav_pages else 1
|
||||
|
||||
# Device/browser breakdown
|
||||
devices = db.query(
|
||||
UserSession.device_type,
|
||||
func.count(UserSession.id).label('cnt')
|
||||
).filter(
|
||||
UserSession.user_id == user_id,
|
||||
UserSession.started_at >= start_30d
|
||||
).group_by(UserSession.device_type).all()
|
||||
|
||||
browsers = db.query(
|
||||
UserSession.browser,
|
||||
func.count(UserSession.id).label('cnt')
|
||||
).filter(
|
||||
UserSession.user_id == user_id,
|
||||
UserSession.started_at >= start_30d
|
||||
).group_by(UserSession.browser).order_by(desc('cnt')).limit(5).all()
|
||||
|
||||
# Hourly activity pattern (24 bars)
|
||||
hourly_sql = text("""
|
||||
SELECT EXTRACT(HOUR FROM started_at)::int as hour, COUNT(*) as cnt
|
||||
FROM user_sessions
|
||||
WHERE user_id = :uid AND started_at >= :start_dt
|
||||
GROUP BY hour ORDER BY hour
|
||||
""")
|
||||
hourly_raw = db.execute(hourly_sql, {'uid': user_id, 'start_dt': start_30d}).fetchall()
|
||||
hourly = {r.hour: r.cnt for r in hourly_raw}
|
||||
max_hourly = max(hourly.values(), default=1) or 1
|
||||
hourly_bars = []
|
||||
for h in range(24):
|
||||
cnt = hourly.get(h, 0)
|
||||
hourly_bars.append({'hour': h, 'count': cnt, 'pct': int(cnt / max_hourly * 100)})
|
||||
|
||||
# Daily engagement trend (30d for Chart.js)
|
||||
trend_labels = []
|
||||
trend_scores = []
|
||||
for i in range(30):
|
||||
d = date.today() - timedelta(days=29 - i)
|
||||
d_start = datetime.combine(d, datetime.min.time())
|
||||
d_end = datetime.combine(d + timedelta(days=1), datetime.min.time())
|
||||
|
||||
d_sessions = db.query(func.count(UserSession.id)).filter(
|
||||
UserSession.user_id == user_id,
|
||||
UserSession.started_at >= d_start,
|
||||
UserSession.started_at < d_end
|
||||
).scalar() or 0
|
||||
|
||||
d_pv = db.query(func.count(PageView.id)).filter(
|
||||
PageView.user_id == user_id,
|
||||
PageView.viewed_at >= d_start,
|
||||
PageView.viewed_at < d_end
|
||||
).scalar() or 0
|
||||
|
||||
daily_score = min(30, d_sessions * 3 + d_pv)
|
||||
trend_labels.append(d.strftime('%d.%m'))
|
||||
trend_scores.append(daily_score)
|
||||
|
||||
# Problem history
|
||||
js_errors_list = db.query(JSError).join(
|
||||
UserSession, JSError.session_id == UserSession.id
|
||||
).filter(
|
||||
UserSession.user_id == user_id
|
||||
).order_by(desc(JSError.occurred_at)).limit(10).all()
|
||||
|
||||
slow_pages_list = db.query(PageView).filter(
|
||||
PageView.user_id == user_id,
|
||||
PageView.load_time_ms > 3000
|
||||
).order_by(desc(PageView.viewed_at)).limit(10).all()
|
||||
|
||||
# Avg sessions per week
|
||||
weeks_active = max(1, (date.today() - (user.created_at.date() if user.created_at else date.today())).days / 7)
|
||||
total_sessions_all = db.query(func.count(UserSession.id)).filter(
|
||||
UserSession.user_id == user_id
|
||||
).scalar() or 0
|
||||
avg_sessions_week = round(total_sessions_all / weeks_active, 1)
|
||||
|
||||
avg_session_dur = db.query(func.avg(UserSession.duration_seconds)).filter(
|
||||
UserSession.user_id == user_id,
|
||||
UserSession.duration_seconds.isnot(None)
|
||||
).scalar() or 0
|
||||
|
||||
return render_template(
|
||||
'admin/user_insights_profile.html',
|
||||
user=user,
|
||||
engagement_score=engagement_score,
|
||||
problem_score=problem_score,
|
||||
timeline=timeline,
|
||||
fav_pages=[{'path': p.path, 'count': p.cnt, 'bar_pct': int(p.cnt / max_fav * 100)} for p in fav_pages],
|
||||
devices=[{'type': d.device_type or 'unknown', 'count': d.cnt} for d in devices],
|
||||
browsers=[{'name': b.browser or 'unknown', 'count': b.cnt} for b in browsers],
|
||||
hourly_bars=hourly_bars,
|
||||
trend_data={'labels': trend_labels, 'scores': trend_scores},
|
||||
js_errors=js_errors_list,
|
||||
slow_pages=slow_pages_list,
|
||||
password_resets=pr_30d,
|
||||
security_alerts_count=sa_7d,
|
||||
avg_sessions_week=avg_sessions_week,
|
||||
avg_session_duration=int(avg_session_dur),
|
||||
search_queries=searches,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"User insights profile error: {e}", exc_info=True)
|
||||
flash('Błąd ładowania profilu użytkownika.', 'error')
|
||||
return redirect(url_for('admin.user_insights'))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# CSV EXPORT
|
||||
# ============================================================
|
||||
|
||||
@bp.route('/user-insights/export')
|
||||
@login_required
|
||||
@role_required(SystemRole.OFFICE_MANAGER)
|
||||
def user_insights_export():
|
||||
"""Export user insights data as CSV."""
|
||||
export_type = request.args.get('type', 'engagement')
|
||||
period = request.args.get('period', 'week')
|
||||
start_date, days = _get_period_dates(period)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
|
||||
if export_type == 'problems':
|
||||
data = _tab_problems(db, start_date, days)
|
||||
writer.writerow(['Użytkownik', 'Email', 'Problem Score', 'Nieudane logowania',
|
||||
'Resety hasła', 'Błędy JS', 'Wolne strony', 'Ostatni login'])
|
||||
for p in data['problem_users']:
|
||||
writer.writerow([
|
||||
p['user'].name, p['user'].email, p['score'],
|
||||
p['failed_logins'], p['password_resets'], p['js_errors'],
|
||||
p['slow_pages'], p['last_login'] or 'Nigdy'
|
||||
])
|
||||
|
||||
elif export_type == 'engagement':
|
||||
data = _tab_engagement(db, start_date, days)
|
||||
writer.writerow(['Użytkownik', 'Email', 'Score', 'Sesje', 'Odsłony',
|
||||
'Zmiana WoW %', 'Status'])
|
||||
for e in data['engagement_list']:
|
||||
writer.writerow([
|
||||
e['user'].name, e['user'].email, e['score'],
|
||||
e['sessions'], e['page_views'],
|
||||
f"{e['wow']}%" if e['wow'] is not None else 'N/A',
|
||||
e['status']
|
||||
])
|
||||
|
||||
elif export_type == 'pages':
|
||||
data = _tab_pages(db, start_date, days)
|
||||
writer.writerow(['Ścieżka', 'Odsłony', 'Unikalni', 'Śr. czas (s)',
|
||||
'Śr. scroll %', 'Śr. ładowanie (ms)'])
|
||||
for p in data['top_pages']:
|
||||
writer.writerow([
|
||||
p['path'], p['views'], p['unique_users'],
|
||||
p['avg_time'], p['avg_scroll'], p['avg_load']
|
||||
])
|
||||
|
||||
output.seek(0)
|
||||
return Response(
|
||||
output.getvalue(),
|
||||
mimetype='text/csv',
|
||||
headers={'Content-Disposition': f'attachment; filename=user_insights_{export_type}_{period}.csv'}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"User insights export error: {e}")
|
||||
flash('Błąd eksportu danych.', 'error')
|
||||
return redirect(url_for('admin.user_insights'))
|
||||
finally:
|
||||
db.close()
|
||||
30
database/migrations/078_user_insights_indexes.sql
Normal file
30
database/migrations/078_user_insights_indexes.sql
Normal file
@ -0,0 +1,30 @@
|
||||
-- Migration 078: User Insights Dashboard - Performance Indexes
|
||||
-- Created: 2026-02-21
|
||||
-- Purpose: Optimize queries for user insights dashboard (problem scoring, engagement, page map, paths)
|
||||
|
||||
-- Page views: user + date for engagement queries
|
||||
CREATE INDEX IF NOT EXISTS idx_pv_user_date ON page_views(user_id, viewed_at DESC);
|
||||
|
||||
-- Page views: session + date for path analysis (entry/exit pages, transitions)
|
||||
CREATE INDEX IF NOT EXISTS idx_pv_session_date ON page_views(session_id, viewed_at);
|
||||
|
||||
-- Page views: path + date for page map (partial index for recent data)
|
||||
CREATE INDEX IF NOT EXISTS idx_pv_path_date ON page_views(path, viewed_at) WHERE viewed_at >= '2026-01-01';
|
||||
|
||||
-- Email logs: user + type for password reset tracking
|
||||
CREATE INDEX IF NOT EXISTS idx_el_user_type ON email_logs(user_id, email_type, created_at DESC);
|
||||
|
||||
-- JS errors: session for joining with user sessions
|
||||
CREATE INDEX IF NOT EXISTS idx_je_session ON js_errors(session_id, occurred_at DESC);
|
||||
|
||||
-- Security alerts: email + date for problem scoring
|
||||
CREATE INDEX IF NOT EXISTS idx_sa_email_date ON security_alerts(user_email, created_at DESC);
|
||||
|
||||
-- User sessions: user + date for engagement/session queries
|
||||
CREATE INDEX IF NOT EXISTS idx_us_user_date ON user_sessions(user_id, started_at DESC);
|
||||
|
||||
-- Search queries: user + date for engagement scoring
|
||||
CREATE INDEX IF NOT EXISTS idx_sq_user_date ON search_queries(user_id, searched_at DESC);
|
||||
|
||||
-- Conversion events: user + date for engagement scoring
|
||||
CREATE INDEX IF NOT EXISTS idx_ce_user_date ON conversion_events(user_id, converted_at DESC);
|
||||
698
templates/admin/user_insights.html
Normal file
698
templates/admin/user_insights.html
Normal file
@ -0,0 +1,698 @@
|
||||
{% 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 { 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; }
|
||||
|
||||
/* 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>
|
||||
|
||||
<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>Błędy JS</th>
|
||||
<th>Wolne strony</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) }}">{{ 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.js_errors }}</td>
|
||||
<td>{{ p.slow_pages }}</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) }}">{{ 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 · {{ 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 (30 dni)</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 %}
|
||||
363
templates/admin/user_insights_profile.html
Normal file
363
templates/admin/user_insights_profile.html
Normal file
@ -0,0 +1,363 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ user.name or user.email }} - User Insights{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.profile-container { max-width: 1400px; margin: 0 auto; padding: var(--spacing-lg); }
|
||||
|
||||
/* Back link */
|
||||
.back-link { display: inline-flex; align-items: center; gap: var(--spacing-xs); color: var(--text-secondary); text-decoration: none; font-size: var(--font-size-sm); margin-bottom: var(--spacing-lg); }
|
||||
.back-link:hover { color: var(--primary); }
|
||||
|
||||
/* Profile header */
|
||||
.profile-header { background: white; padding: var(--spacing-xl); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); margin-bottom: var(--spacing-xl); display: flex; gap: var(--spacing-xl); align-items: flex-start; flex-wrap: wrap; }
|
||||
.profile-info { flex: 1; min-width: 250px; }
|
||||
.profile-name { font-size: var(--font-size-2xl); font-weight: 700; margin-bottom: var(--spacing-xs); }
|
||||
.profile-email { color: var(--text-secondary); font-size: var(--font-size-sm); margin-bottom: var(--spacing-md); }
|
||||
.profile-meta { display: flex; gap: var(--spacing-lg); flex-wrap: wrap; font-size: var(--font-size-sm); color: var(--text-secondary); }
|
||||
.profile-meta-item { display: flex; flex-direction: column; gap: 2px; }
|
||||
.profile-meta-label { font-size: var(--font-size-xs); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.profile-meta-value { font-weight: 600; color: var(--text-primary); }
|
||||
|
||||
/* Gauges row */
|
||||
.gauges-row { display: flex; gap: var(--spacing-xl); }
|
||||
.gauge-wrapper { text-align: center; }
|
||||
.gauge { position: relative; width: 120px; height: 60px; overflow: hidden; }
|
||||
.gauge-bg { position: absolute; width: 120px; height: 120px; border-radius: 50%; border: 10px solid var(--border); border-bottom-color: transparent; border-left-color: transparent; transform: rotate(225deg); }
|
||||
.gauge-fill { position: absolute; width: 120px; height: 120px; border-radius: 50%; border: 10px solid; border-bottom-color: transparent; border-left-color: transparent; transition: transform 0.8s ease-out; }
|
||||
.gauge-fill.low { border-top-color: #22c55e; border-right-color: #22c55e; }
|
||||
.gauge-fill.medium { border-top-color: #f59e0b; border-right-color: #f59e0b; }
|
||||
.gauge-fill.high { border-top-color: #ef4444; border-right-color: #ef4444; }
|
||||
.gauge-value { position: absolute; bottom: 0; left: 50%; transform: translateX(-50%); font-size: var(--font-size-xl); font-weight: 700; }
|
||||
.gauge-label { font-size: var(--font-size-xs); color: var(--text-secondary); 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); }
|
||||
|
||||
/* Two columns */
|
||||
.two-columns { display: grid; grid-template-columns: 1fr 1fr; gap: var(--spacing-xl); }
|
||||
@media (max-width: 1024px) { .two-columns { grid-template-columns: 1fr; } }
|
||||
|
||||
/* Three columns */
|
||||
.three-columns { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--spacing-xl); }
|
||||
@media (max-width: 1024px) { .three-columns { grid-template-columns: 1fr; } }
|
||||
|
||||
/* Timeline */
|
||||
.timeline { position: relative; padding-left: var(--spacing-xl); }
|
||||
.timeline::before { content: ''; position: absolute; left: 12px; top: 0; bottom: 0; width: 2px; background: var(--border); }
|
||||
.timeline-item { position: relative; padding-bottom: var(--spacing-md); display: flex; gap: var(--spacing-md); align-items: flex-start; }
|
||||
.timeline-dot { width: 24px; height: 24px; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; margin-left: -30px; z-index: 1; font-size: 10px; }
|
||||
.timeline-dot.login { background: #dbeafe; color: #2563eb; }
|
||||
.timeline-dot.pageview { background: #dcfce7; color: #16a34a; }
|
||||
.timeline-dot.search { background: #fef3c7; color: #d97706; }
|
||||
.timeline-dot.conversion { background: #ede9fe; color: #7c3aed; }
|
||||
.timeline-dot.problem { background: #fee2e2; color: #dc2626; }
|
||||
.timeline-body { flex: 1; }
|
||||
.timeline-desc { font-size: var(--font-size-sm); }
|
||||
.timeline-time { font-size: var(--font-size-xs); color: var(--text-muted); }
|
||||
|
||||
/* Bars */
|
||||
.bar-row { display: flex; align-items: center; gap: var(--spacing-sm); margin-bottom: var(--spacing-sm); }
|
||||
.bar-label { width: 200px; min-width: 200px; font-size: var(--font-size-xs); font-family: monospace; word-break: break-all; color: var(--text-secondary); }
|
||||
.bar-track { flex: 1; height: 20px; background: var(--background); border-radius: var(--radius-sm); overflow: hidden; }
|
||||
.bar-fill { height: 100%; border-radius: var(--radius-sm); background: var(--primary); }
|
||||
.bar-value { font-size: var(--font-size-xs); font-weight: 600; min-width: 30px; text-align: right; }
|
||||
|
||||
/* Hourly bars */
|
||||
.hourly-bars { display: flex; align-items: flex-end; gap: 2px; height: 60px; }
|
||||
.hourly-bar { flex: 1; background: var(--primary); border-radius: 2px 2px 0 0; min-width: 8px; opacity: 0.7; position: relative; }
|
||||
.hourly-bar:hover { opacity: 1; }
|
||||
.hourly-labels { display: flex; gap: 2px; }
|
||||
.hourly-label { flex: 1; text-align: center; font-size: 9px; color: var(--text-muted); min-width: 8px; }
|
||||
|
||||
/* 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); }
|
||||
.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; }
|
||||
|
||||
/* Stats grid */
|
||||
.stats-mini { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: var(--spacing-md); margin-bottom: var(--spacing-lg); }
|
||||
.stat-mini { text-align: center; padding: var(--spacing-md); background: var(--background); border-radius: var(--radius); }
|
||||
.stat-mini-value { font-size: var(--font-size-xl); font-weight: 700; }
|
||||
.stat-mini-label { font-size: var(--font-size-xs); color: var(--text-secondary); margin-top: 2px; }
|
||||
|
||||
/* Chart */
|
||||
.chart-container { position: relative; height: 250px; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.profile-header { flex-direction: column; }
|
||||
.gauges-row { flex-direction: row; gap: var(--spacing-md); }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="profile-container">
|
||||
|
||||
<a href="{{ url_for('admin.user_insights', tab='engagement') }}" class="back-link">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
|
||||
Powrót do User Insights
|
||||
</a>
|
||||
|
||||
<!-- Profile Header -->
|
||||
<div class="profile-header">
|
||||
<div class="profile-info">
|
||||
<div class="profile-name">{{ user.name or 'Bez nazwy' }}</div>
|
||||
<div class="profile-email">{{ user.email }}</div>
|
||||
<div class="profile-meta">
|
||||
{% if user.company %}
|
||||
<div class="profile-meta-item">
|
||||
<span class="profile-meta-label">Firma</span>
|
||||
<span class="profile-meta-value">{{ user.company.name }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="profile-meta-item">
|
||||
<span class="profile-meta-label">Rola</span>
|
||||
<span class="profile-meta-value">{{ user.role }}</span>
|
||||
</div>
|
||||
<div class="profile-meta-item">
|
||||
<span class="profile-meta-label">Rejestracja</span>
|
||||
<span class="profile-meta-value">{{ user.created_at.strftime('%d.%m.%Y') if user.created_at else 'N/A' }}</span>
|
||||
</div>
|
||||
<div class="profile-meta-item">
|
||||
<span class="profile-meta-label">Ostatni login</span>
|
||||
<span class="profile-meta-value">{{ user.last_login.strftime('%d.%m.%Y %H:%M') if user.last_login else 'Nigdy' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gauges-row">
|
||||
<!-- Engagement gauge -->
|
||||
<div class="gauge-wrapper">
|
||||
<div class="gauge">
|
||||
<div class="gauge-bg"></div>
|
||||
{% set eng_class = 'low' if engagement_score >= 50 else ('medium' if engagement_score >= 20 else 'high') %}
|
||||
<div class="gauge-fill {{ eng_class }}" style="transform: rotate({{ 225 + (engagement_score / 100 * 180) }}deg);"></div>
|
||||
<div class="gauge-value">{{ engagement_score }}</div>
|
||||
</div>
|
||||
<div class="gauge-label">Zaangażowanie</div>
|
||||
</div>
|
||||
|
||||
<!-- Problem gauge -->
|
||||
<div class="gauge-wrapper">
|
||||
<div class="gauge">
|
||||
<div class="gauge-bg"></div>
|
||||
{% set prob_class = 'high' if problem_score >= 51 else ('medium' if problem_score >= 21 else 'low') %}
|
||||
<div class="gauge-fill {{ prob_class }}" style="transform: rotate({{ 225 + (problem_score / 100 * 180) }}deg);"></div>
|
||||
<div class="gauge-value">{{ problem_score }}</div>
|
||||
</div>
|
||||
<div class="gauge-label">Problemy</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick stats -->
|
||||
<div class="stats-mini">
|
||||
<div class="stat-mini">
|
||||
<div class="stat-mini-value">{{ avg_sessions_week }}</div>
|
||||
<div class="stat-mini-label">Śr. sesji/tydzień</div>
|
||||
</div>
|
||||
<div class="stat-mini">
|
||||
<div class="stat-mini-value">{{ (avg_session_duration / 60)|round(1) }}m</div>
|
||||
<div class="stat-mini-label">Śr. czas sesji</div>
|
||||
</div>
|
||||
<div class="stat-mini">
|
||||
<div class="stat-mini-value">{{ password_resets }}</div>
|
||||
<div class="stat-mini-label">Resety hasła (30d)</div>
|
||||
</div>
|
||||
<div class="stat-mini">
|
||||
<div class="stat-mini-value">{{ security_alerts_count }}</div>
|
||||
<div class="stat-mini-label">Alerty (7d)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="two-columns">
|
||||
<!-- Timeline -->
|
||||
<div class="section-card">
|
||||
<h2>Oś czasu aktywności</h2>
|
||||
{% if timeline %}
|
||||
<div class="timeline" style="max-height: 600px; overflow-y: auto;">
|
||||
{% for event in timeline %}
|
||||
<div class="timeline-item">
|
||||
<div class="timeline-dot {{ event.type }}">
|
||||
{% if event.icon == 'key' %}🔑{% elif event.icon == 'eye' %}👁{% elif event.icon == 'search' %}🔍{% elif event.icon == 'check' %}✓{% elif event.icon == 'alert' %}⚠{% elif event.icon == 'shield' %}🛡{% else %}•{% endif %}
|
||||
</div>
|
||||
<div class="timeline-body">
|
||||
<div class="timeline-desc">{{ event.desc }}</div>
|
||||
<div class="timeline-time">{{ event.time.strftime('%d.%m.%Y %H:%M') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p style="color: var(--text-muted); text-align: center;">Brak zdarzeń.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Behavioral stats -->
|
||||
<div>
|
||||
<!-- Favorite pages -->
|
||||
<div class="section-card">
|
||||
<h2>Ulubione strony (30d)</h2>
|
||||
{% for p in fav_pages %}
|
||||
<div class="bar-row">
|
||||
<div class="bar-label">{{ p.path }}</div>
|
||||
<div class="bar-track">
|
||||
<div class="bar-fill" style="width: {{ p.bar_pct }}%;"></div>
|
||||
</div>
|
||||
<div class="bar-value">{{ p.count }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if not fav_pages %}
|
||||
<p style="color: var(--text-muted); text-align: center;">Brak danych.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Hourly pattern -->
|
||||
<div class="section-card">
|
||||
<h2>Wzorzec godzinowy (30d)</h2>
|
||||
<div class="hourly-bars">
|
||||
{% set max_h = hourly_bars|map(attribute='count')|max if hourly_bars|map(attribute='count')|max > 0 else 1 %}
|
||||
{% for h in hourly_bars %}
|
||||
<div class="hourly-bar" style="height: {{ (h.count / max_h * 55 + 5)|int }}px;" title="{{ h.hour }}:00 — {{ h.count }}"></div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="hourly-labels">
|
||||
{% for h in hourly_bars %}
|
||||
<div class="hourly-label">{% if h.hour % 3 == 0 %}{{ h.hour }}{% endif %}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Devices & Browsers -->
|
||||
<div class="section-card">
|
||||
<h2>Urządzenia i przeglądarki</h2>
|
||||
<div style="display: flex; gap: var(--spacing-xl); flex-wrap: wrap;">
|
||||
<div style="flex: 1; min-width: 120px;">
|
||||
<strong style="font-size: var(--font-size-xs); color: var(--text-secondary);">URZĄDZENIA</strong>
|
||||
{% for d in devices %}
|
||||
<div style="margin-top: var(--spacing-sm); font-size: var(--font-size-sm);">
|
||||
{{ d.type|capitalize }}: <strong>{{ d.count }}</strong>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div style="flex: 1; min-width: 120px;">
|
||||
<strong style="font-size: var(--font-size-xs); color: var(--text-secondary);">PRZEGLĄDARKI</strong>
|
||||
{% for b in browsers %}
|
||||
<div style="margin-top: var(--spacing-sm); font-size: var(--font-size-sm);">
|
||||
{{ b.name }}: <strong>{{ b.count }}</strong>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Problem history -->
|
||||
<div class="section-card">
|
||||
<h2>Historia problemów</h2>
|
||||
<div class="two-columns">
|
||||
<div>
|
||||
<h3 style="font-size: var(--font-size-sm); font-weight: 600; margin-bottom: var(--spacing-md);">Błędy JS</h3>
|
||||
{% if js_errors %}
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr><th>Wiadomość</th><th>Strona</th><th>Data</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for e in js_errors %}
|
||||
<tr>
|
||||
<td style="max-width: 300px; word-break: break-all; font-size: var(--font-size-xs);">{{ e.message[:100] }}</td>
|
||||
<td style="font-size: var(--font-size-xs);">{{ e.url[:50] if e.url else 'N/A' }}</td>
|
||||
<td style="white-space: nowrap;">{{ e.occurred_at.strftime('%d.%m %H:%M') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p style="color: var(--text-muted);">Brak błędów JS.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<h3 style="font-size: var(--font-size-sm); font-weight: 600; margin-bottom: var(--spacing-md);">Wolne strony (> 3s)</h3>
|
||||
{% if slow_pages %}
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr><th>Strona</th><th>Czas</th><th>Data</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in slow_pages %}
|
||||
<tr>
|
||||
<td style="font-family: monospace; font-size: var(--font-size-xs);">{{ p.path }}</td>
|
||||
<td style="color: var(--error); font-weight: 600;">{{ p.load_time_ms }}ms</td>
|
||||
<td style="white-space: nowrap;">{{ p.viewed_at.strftime('%d.%m %H:%M') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p style="color: var(--text-muted);">Brak wolnych stron.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Engagement trend chart -->
|
||||
<div class="section-card">
|
||||
<h2>Trend zaangażowania (30 dni)</h2>
|
||||
<div class="chart-container">
|
||||
<canvas id="trendChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search queries -->
|
||||
{% if search_queries %}
|
||||
<div class="section-card">
|
||||
<h2>Ostatnie wyszukiwania</h2>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: var(--spacing-sm);">
|
||||
{% for s in search_queries %}
|
||||
<span style="padding: 4px 12px; background: var(--background); border-radius: var(--radius); font-size: var(--font-size-sm);">
|
||||
"{{ s.query }}" <span style="color: var(--text-muted); font-size: var(--font-size-xs);">{{ s.searched_at.strftime('%d.%m') }}</span>
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
const trendData = {{ trend_data|tojson|safe }};
|
||||
const trendCtx = document.getElementById('trendChart').getContext('2d');
|
||||
new Chart(trendCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: trendData.labels,
|
||||
datasets: [{
|
||||
label: 'Dzienny score',
|
||||
data: trendData.scores,
|
||||
borderColor: '#6366f1',
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 2
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { grid: { display: false } },
|
||||
y: { beginAtZero: true, max: 30 }
|
||||
}
|
||||
}
|
||||
});
|
||||
{% endblock %}
|
||||
Loading…
Reference in New Issue
Block a user