feat: Add user analytics panel (/admin/analytics)

- Track user sessions, page views, clicks
- Measure engagement score and session duration
- Device breakdown (desktop/mobile/tablet)
- User rankings by activity
- Popular pages statistics
- Recent sessions feed
- SQL migration for analytics tables
- JavaScript tracker for frontend events

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-01-13 19:40:37 +01:00
parent 847ec1f12f
commit a148d464af
6 changed files with 1717 additions and 1 deletions

394
app.py
View File

@ -32,6 +32,8 @@ from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from werkzeug.security import generate_password_hash, check_password_hash
from dotenv import load_dotenv
from user_agents import parse as parse_user_agent
import uuid
# Load environment variables (override any existing env vars)
# Try .env first, then nordabiz_config.txt for production flexibility
@ -114,7 +116,12 @@ from database import (
ITAudit,
KRSAudit,
CompanyPKD,
CompanyFinancialReport
CompanyFinancialReport,
UserSession,
PageView,
UserClick,
AnalyticsDaily,
PopularPagesDaily
)
# Import services
@ -337,6 +344,157 @@ def create_news_notification(company_id, news_id, news_title):
db.close()
# ============================================================
# USER ANALYTICS - TRACKING HELPERS
# ============================================================
# Global variable to store current page_view_id for templates
_current_page_view_id = {}
def get_or_create_analytics_session():
"""
Get existing analytics session or create new one.
Returns the database session ID (integer).
"""
analytics_session_id = session.get('analytics_session_id')
if not analytics_session_id:
analytics_session_id = str(uuid.uuid4())
session['analytics_session_id'] = analytics_session_id
db = SessionLocal()
try:
user_session = db.query(UserSession).filter_by(session_id=analytics_session_id).first()
if not user_session:
# Parse user agent
ua_string = request.headers.get('User-Agent', '')
try:
ua = parse_user_agent(ua_string)
device_type = 'mobile' if ua.is_mobile else ('tablet' if ua.is_tablet else 'desktop')
browser = ua.browser.family
browser_version = ua.browser.version_string
os_name = ua.os.family
os_version = ua.os.version_string
except Exception:
device_type = 'desktop'
browser = 'Unknown'
browser_version = ''
os_name = 'Unknown'
os_version = ''
user_session = UserSession(
session_id=analytics_session_id,
user_id=current_user.id if current_user.is_authenticated else None,
ip_address=request.remote_addr,
user_agent=ua_string[:2000] if ua_string else None,
device_type=device_type,
browser=browser[:50] if browser else None,
browser_version=browser_version[:20] if browser_version else None,
os=os_name[:50] if os_name else None,
os_version=os_version[:20] if os_version else None
)
db.add(user_session)
db.commit()
db.refresh(user_session)
else:
# Update last activity
user_session.last_activity_at = datetime.now()
if current_user.is_authenticated and not user_session.user_id:
user_session.user_id = current_user.id
db.commit()
return user_session.id
except Exception as e:
logger.error(f"Analytics session error: {e}")
db.rollback()
return None
finally:
db.close()
@app.before_request
def track_page_view():
"""Track page views (excluding static files and API calls)"""
# Skip static files
if request.path.startswith('/static'):
return
# Skip API calls except selected ones
if request.path.startswith('/api'):
return
# Skip analytics tracking endpoints
if request.path in ['/api/analytics/track', '/api/analytics/heartbeat']:
return
# Skip health checks
if request.path == '/health':
return
# Skip favicon
if request.path == '/favicon.ico':
return
try:
session_db_id = get_or_create_analytics_session()
if not session_db_id:
return
db = SessionLocal()
try:
page_view = PageView(
session_id=session_db_id,
user_id=current_user.id if current_user.is_authenticated else None,
url=request.url[:2000] if request.url else '',
path=request.path[:500] if request.path else '/',
referrer=request.referrer[:2000] if request.referrer else None
)
# Extract company_id from path if on company page
if request.path.startswith('/company/'):
try:
slug = request.path.split('/')[2].split('?')[0]
company = db.query(Company).filter_by(slug=slug).first()
if company:
page_view.company_id = company.id
except Exception:
pass
db.add(page_view)
# Update session page count
user_session = db.query(UserSession).filter_by(id=session_db_id).first()
if user_session:
user_session.page_views_count = (user_session.page_views_count or 0) + 1
db.commit()
# Store page_view_id for click tracking (in request context)
_current_page_view_id[id(request)] = page_view.id
except Exception as e:
logger.error(f"Page view tracking error: {e}")
db.rollback()
finally:
db.close()
except Exception as e:
logger.error(f"Page view tracking outer error: {e}")
@app.context_processor
def inject_page_view_id():
"""Inject page_view_id into all templates for JS tracking"""
page_view_id = _current_page_view_id.get(id(request), '')
return {'page_view_id': page_view_id}
@app.teardown_request
def cleanup_page_view_id(exception=None):
"""Clean up page_view_id from global dict after request"""
_current_page_view_id.pop(id(request), None)
# ============================================================
# SECURITY MIDDLEWARE & HELPERS
# ============================================================
@ -2937,6 +3095,92 @@ def api_notifications_unread_count():
db.close()
# ============================================================
# USER ANALYTICS API ROUTES
# ============================================================
@app.route('/api/analytics/track', methods=['POST'])
def api_analytics_track():
"""Track clicks and interactions from frontend"""
data = request.get_json()
if not data:
return jsonify({'error': 'No data'}), 400
analytics_session_id = session.get('analytics_session_id')
if not analytics_session_id:
return jsonify({'error': 'No session'}), 400
db = SessionLocal()
try:
user_session = db.query(UserSession).filter_by(session_id=analytics_session_id).first()
if not user_session:
return jsonify({'error': 'Session not found'}), 404
event_type = data.get('type')
if event_type == 'click':
click = UserClick(
session_id=user_session.id,
page_view_id=data.get('page_view_id'),
user_id=current_user.id if current_user.is_authenticated else None,
element_type=data.get('element_type', '')[:50] if data.get('element_type') else None,
element_id=data.get('element_id', '')[:100] if data.get('element_id') else None,
element_text=(data.get('element_text', '') or '')[:255],
element_class=(data.get('element_class', '') or '')[:500],
target_url=data.get('target_url', '')[:2000] if data.get('target_url') else None,
x_position=data.get('x'),
y_position=data.get('y')
)
db.add(click)
user_session.clicks_count = (user_session.clicks_count or 0) + 1
db.commit()
elif event_type == 'page_time':
# Update time on page
page_view_id = data.get('page_view_id')
time_seconds = data.get('time_seconds')
if page_view_id and time_seconds:
page_view = db.query(PageView).filter_by(id=page_view_id).first()
if page_view:
page_view.time_on_page_seconds = min(time_seconds, 86400) # Max 24h
db.commit()
return jsonify({'success': True}), 200
except Exception as e:
logger.error(f"Analytics track error: {e}")
db.rollback()
return jsonify({'error': 'Internal error'}), 500
finally:
db.close()
@app.route('/api/analytics/heartbeat', methods=['POST'])
def api_analytics_heartbeat():
"""Keep session alive and update duration"""
analytics_session_id = session.get('analytics_session_id')
if not analytics_session_id:
return jsonify({'success': False}), 200
db = SessionLocal()
try:
user_session = db.query(UserSession).filter_by(session_id=analytics_session_id).first()
if user_session:
user_session.last_activity_at = datetime.now()
user_session.duration_seconds = int(
(datetime.now() - user_session.started_at).total_seconds()
)
db.commit()
return jsonify({'success': True}), 200
except Exception as e:
logger.error(f"Analytics heartbeat error: {e}")
db.rollback()
return jsonify({'success': False}), 200
finally:
db.close()
# ============================================================
# RECOMMENDATIONS API ROUTES
# ============================================================
@ -6390,6 +6634,154 @@ def chat_analytics():
db.close()
@app.route('/admin/analytics')
@login_required
def admin_analytics():
"""Admin dashboard for user analytics - sessions, page views, clicks"""
if not current_user.is_admin:
flash('Brak uprawnien do tej strony.', 'error')
return redirect(url_for('dashboard'))
from sqlalchemy import func, desc
from sqlalchemy.orm import joinedload
from datetime import date, timedelta
period = request.args.get('period', 'week')
user_id = request.args.get('user_id', type=int)
# Period calculation
today = date.today()
if period == 'day':
start_date = today
elif period == 'week':
start_date = today - timedelta(days=7)
elif period == 'month':
start_date = today - timedelta(days=30)
else:
start_date = None
db = SessionLocal()
try:
# Base query for sessions in period
sessions_query = db.query(UserSession)
if start_date:
sessions_query = sessions_query.filter(
func.date(UserSession.started_at) >= start_date
)
# Overall stats
total_sessions = sessions_query.count()
unique_users = sessions_query.filter(
UserSession.user_id.isnot(None)
).distinct(UserSession.user_id).count()
total_page_views = db.query(func.sum(UserSession.page_views_count)).filter(
func.date(UserSession.started_at) >= start_date if start_date else True
).scalar() or 0
total_clicks = db.query(func.sum(UserSession.clicks_count)).filter(
func.date(UserSession.started_at) >= start_date if start_date else True
).scalar() or 0
avg_duration = db.query(func.avg(UserSession.duration_seconds)).filter(
func.date(UserSession.started_at) >= start_date if start_date else True,
UserSession.duration_seconds.isnot(None)
).scalar() or 0
stats = {
'total_sessions': total_sessions,
'unique_users': unique_users,
'total_page_views': int(total_page_views),
'total_clicks': int(total_clicks),
'avg_duration': float(avg_duration)
}
# Device breakdown
device_query = db.query(
UserSession.device_type,
func.count(UserSession.id)
)
if start_date:
device_query = device_query.filter(
func.date(UserSession.started_at) >= start_date
)
device_stats = dict(device_query.group_by(UserSession.device_type).all())
# Top users by engagement
user_query = db.query(
User.id,
User.name,
User.email,
func.count(UserSession.id).label('sessions'),
func.sum(UserSession.page_views_count).label('page_views'),
func.sum(UserSession.clicks_count).label('clicks'),
func.sum(UserSession.duration_seconds).label('total_time')
).join(UserSession, User.id == UserSession.user_id)
if start_date:
user_query = user_query.filter(
func.date(UserSession.started_at) >= start_date
)
user_rankings = user_query.group_by(User.id).order_by(
desc('page_views')
).limit(20).all()
# Popular pages
page_query = 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')
)
if start_date:
page_query = page_query.filter(
func.date(PageView.viewed_at) >= start_date
)
popular_pages = page_query.group_by(PageView.path).order_by(
desc('views')
).limit(20).all()
# Recent sessions (last 50)
recent_sessions = db.query(UserSession).options(
joinedload(UserSession.user)
).order_by(UserSession.started_at.desc()).limit(50).all()
# Single user detail (if requested)
user_detail = None
if user_id:
user_obj = db.query(User).filter_by(id=user_id).first()
user_sessions = db.query(UserSession).filter_by(user_id=user_id).order_by(
UserSession.started_at.desc()
).limit(20).all()
user_pages = db.query(PageView).filter_by(user_id=user_id).order_by(
PageView.viewed_at.desc()
).limit(50).all()
user_detail = {
'user': user_obj,
'sessions': user_sessions,
'pages': user_pages
}
return render_template(
'admin/analytics_dashboard.html',
stats=stats,
device_stats=device_stats,
user_rankings=user_rankings,
popular_pages=popular_pages,
recent_sessions=recent_sessions,
user_detail=user_detail,
current_period=period
)
except Exception as e:
logger.error(f"Admin analytics error: {e}")
flash('Blad podczas ladowania analityki.', 'error')
return redirect(url_for('admin_panel'))
finally:
db.close()
@app.route('/api/admin/ai-learning-status')
@login_required
def api_ai_learning_status():

View File

@ -2349,6 +2349,181 @@ class CompanyFinancialReport(Base):
return f'<CompanyFinancialReport {self.period_start} - {self.period_end}>'
# ============================================================
# USER ANALYTICS - SESJE I AKTYWNOŚĆ
# ============================================================
class UserSession(Base):
"""
Sesje użytkowników portalu.
Śledzi czas trwania sesji, urządzenie, lokalizację i aktywność.
"""
__tablename__ = 'user_sessions'
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=True)
session_id = Column(String(100), unique=True, nullable=False, index=True)
# Czas sesji
started_at = Column(DateTime, nullable=False, default=datetime.now)
ended_at = Column(DateTime, nullable=True)
last_activity_at = Column(DateTime, nullable=False, default=datetime.now)
duration_seconds = Column(Integer, nullable=True)
# Urządzenie
ip_address = Column(String(45), nullable=True)
user_agent = Column(Text, nullable=True)
device_type = Column(String(20), nullable=True) # desktop, mobile, tablet
browser = Column(String(50), nullable=True)
browser_version = Column(String(20), nullable=True)
os = Column(String(50), nullable=True)
os_version = Column(String(20), nullable=True)
# Lokalizacja (z IP)
country = Column(String(100), nullable=True)
city = Column(String(100), nullable=True)
region = Column(String(100), nullable=True)
# Metryki sesji
page_views_count = Column(Integer, default=0)
clicks_count = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.now)
# Relationships
user = relationship('User', backref='analytics_sessions')
page_views = relationship('PageView', back_populates='session', cascade='all, delete-orphan')
def __repr__(self):
return f"<UserSession {self.session_id[:8]}... user={self.user_id}>"
class PageView(Base):
"""
Wyświetlenia stron przez użytkowników.
Śledzi odwiedzone strony i czas spędzony na każdej z nich.
"""
__tablename__ = 'page_views'
id = Column(Integer, primary_key=True)
session_id = Column(Integer, ForeignKey('user_sessions.id', ondelete='CASCADE'), nullable=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
# Strona
url = Column(String(2000), nullable=False)
path = Column(String(500), nullable=False, index=True)
page_title = Column(String(500), nullable=True)
referrer = Column(String(2000), nullable=True)
# Czas
viewed_at = Column(DateTime, nullable=False, default=datetime.now, index=True)
time_on_page_seconds = Column(Integer, nullable=True)
# Kontekst
company_id = Column(Integer, ForeignKey('companies.id', ondelete='SET NULL'), nullable=True)
created_at = Column(DateTime, default=datetime.now)
# Relationships
session = relationship('UserSession', back_populates='page_views')
clicks = relationship('UserClick', back_populates='page_view', cascade='all, delete-orphan')
def __repr__(self):
return f"<PageView {self.path}>"
class UserClick(Base):
"""
Kliknięcia elementów na stronach.
Śledzi interakcje użytkowników z elementami UI.
"""
__tablename__ = 'user_clicks'
id = Column(Integer, primary_key=True)
session_id = Column(Integer, ForeignKey('user_sessions.id', ondelete='CASCADE'), nullable=True)
page_view_id = Column(Integer, ForeignKey('page_views.id', ondelete='CASCADE'), nullable=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
# Element kliknięty
element_type = Column(String(50), nullable=True) # button, link, card, nav, form
element_id = Column(String(100), nullable=True)
element_text = Column(String(255), nullable=True)
element_class = Column(String(500), nullable=True)
target_url = Column(String(2000), nullable=True)
# Pozycja kliknięcia
x_position = Column(Integer, nullable=True)
y_position = Column(Integer, nullable=True)
clicked_at = Column(DateTime, nullable=False, default=datetime.now, index=True)
# Relationships
page_view = relationship('PageView', back_populates='clicks')
def __repr__(self):
return f"<UserClick {self.element_type} at {self.clicked_at}>"
class AnalyticsDaily(Base):
"""
Dzienne statystyki agregowane.
Automatycznie aktualizowane przez trigger PostgreSQL.
"""
__tablename__ = 'analytics_daily'
id = Column(Integer, primary_key=True)
date = Column(Date, unique=True, nullable=False, index=True)
# Sesje
total_sessions = Column(Integer, default=0)
unique_users = Column(Integer, default=0)
new_users = Column(Integer, default=0)
returning_users = Column(Integer, default=0)
anonymous_sessions = Column(Integer, default=0)
# Aktywność
total_page_views = Column(Integer, default=0)
total_clicks = Column(Integer, default=0)
avg_session_duration_seconds = Column(Integer, nullable=True)
avg_pages_per_session = Column(Numeric(5, 2), nullable=True)
# Urządzenia
desktop_sessions = Column(Integer, default=0)
mobile_sessions = Column(Integer, default=0)
tablet_sessions = Column(Integer, default=0)
# Engagement
bounce_rate = Column(Numeric(5, 2), nullable=True)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
def __repr__(self):
return f"<AnalyticsDaily {self.date}>"
class PopularPagesDaily(Base):
"""
Popularne strony - dzienne agregaty.
"""
__tablename__ = 'popular_pages_daily'
id = Column(Integer, primary_key=True)
date = Column(Date, nullable=False, index=True)
path = Column(String(500), nullable=False)
page_title = Column(String(500), nullable=True)
views_count = Column(Integer, default=0)
unique_visitors = Column(Integer, default=0)
avg_time_seconds = Column(Integer, nullable=True)
__table_args__ = (
UniqueConstraint('date', 'path', name='uq_popular_pages_date_path'),
)
def __repr__(self):
return f"<PopularPagesDaily {self.date} {self.path}>"
# ============================================================
# DATABASE INITIALIZATION
# ============================================================

View File

@ -0,0 +1,250 @@
-- Migration: 014_user_analytics.sql
-- Description: User activity tracking and analytics
-- Created: 2026-01-13
-- Author: Claude
-- ============================================================
-- TABELE ANALITYKI UŻYTKOWNIKÓW
-- ============================================================
-- Sesje użytkowników
CREATE TABLE IF NOT EXISTS user_sessions (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
session_id VARCHAR(100) UNIQUE NOT NULL,
-- Czas sesji
started_at TIMESTAMP NOT NULL DEFAULT NOW(),
ended_at TIMESTAMP,
last_activity_at TIMESTAMP NOT NULL DEFAULT NOW(),
duration_seconds INTEGER,
-- Urządzenie
ip_address VARCHAR(45),
user_agent TEXT,
device_type VARCHAR(20), -- desktop, mobile, tablet
browser VARCHAR(50),
browser_version VARCHAR(20),
os VARCHAR(50),
os_version VARCHAR(20),
-- Lokalizacja (z IP)
country VARCHAR(100),
city VARCHAR(100),
region VARCHAR(100),
-- Metryki sesji
page_views_count INTEGER DEFAULT 0,
clicks_count INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_user_sessions_started ON user_sessions(started_at);
CREATE INDEX IF NOT EXISTS idx_user_sessions_session_id ON user_sessions(session_id);
-- Wyświetlenia stron
CREATE TABLE IF NOT EXISTS page_views (
id SERIAL PRIMARY KEY,
session_id INTEGER REFERENCES user_sessions(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
-- Strona
url VARCHAR(2000) NOT NULL,
path VARCHAR(500) NOT NULL,
page_title VARCHAR(500),
referrer VARCHAR(2000),
-- Czas
viewed_at TIMESTAMP NOT NULL DEFAULT NOW(),
time_on_page_seconds INTEGER,
-- Kontekst
company_id INTEGER REFERENCES companies(id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_page_views_session ON page_views(session_id);
CREATE INDEX IF NOT EXISTS idx_page_views_user ON page_views(user_id);
CREATE INDEX IF NOT EXISTS idx_page_views_path ON page_views(path);
CREATE INDEX IF NOT EXISTS idx_page_views_viewed ON page_views(viewed_at);
CREATE INDEX IF NOT EXISTS idx_page_views_company ON page_views(company_id);
-- Kliknięcia użytkowników
CREATE TABLE IF NOT EXISTS user_clicks (
id SERIAL PRIMARY KEY,
session_id INTEGER REFERENCES user_sessions(id) ON DELETE CASCADE,
page_view_id INTEGER REFERENCES page_views(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
-- Element kliknięty
element_type VARCHAR(50), -- button, link, card, nav, form
element_id VARCHAR(100),
element_text VARCHAR(255),
element_class VARCHAR(500),
target_url VARCHAR(2000),
-- Pozycja
x_position INTEGER,
y_position INTEGER,
clicked_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_user_clicks_session ON user_clicks(session_id);
CREATE INDEX IF NOT EXISTS idx_user_clicks_element ON user_clicks(element_type);
CREATE INDEX IF NOT EXISTS idx_user_clicks_clicked ON user_clicks(clicked_at);
-- Dzienne statystyki (agregowane)
CREATE TABLE IF NOT EXISTS analytics_daily (
id SERIAL PRIMARY KEY,
date DATE UNIQUE NOT NULL,
-- Sesje
total_sessions INTEGER DEFAULT 0,
unique_users INTEGER DEFAULT 0,
new_users INTEGER DEFAULT 0,
returning_users INTEGER DEFAULT 0,
anonymous_sessions INTEGER DEFAULT 0,
-- Aktywność
total_page_views INTEGER DEFAULT 0,
total_clicks INTEGER DEFAULT 0,
avg_session_duration_seconds INTEGER,
avg_pages_per_session NUMERIC(5,2),
-- Urządzenia
desktop_sessions INTEGER DEFAULT 0,
mobile_sessions INTEGER DEFAULT 0,
tablet_sessions INTEGER DEFAULT 0,
-- Engagement
bounce_rate NUMERIC(5,2), -- % sesji z 1 page view
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_analytics_daily_date ON analytics_daily(date);
-- Popularne strony (dzienne)
CREATE TABLE IF NOT EXISTS popular_pages_daily (
id SERIAL PRIMARY KEY,
date DATE NOT NULL,
path VARCHAR(500) NOT NULL,
page_title VARCHAR(500),
views_count INTEGER DEFAULT 0,
unique_visitors INTEGER DEFAULT 0,
avg_time_seconds INTEGER,
UNIQUE(date, path)
);
CREATE INDEX IF NOT EXISTS idx_popular_pages_date ON popular_pages_daily(date);
-- ============================================================
-- GRANT PERMISSIONS
-- ============================================================
GRANT ALL ON TABLE user_sessions TO nordabiz_app;
GRANT ALL ON TABLE page_views TO nordabiz_app;
GRANT ALL ON TABLE user_clicks TO nordabiz_app;
GRANT ALL ON TABLE analytics_daily TO nordabiz_app;
GRANT ALL ON TABLE popular_pages_daily TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE user_sessions_id_seq TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE page_views_id_seq TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE user_clicks_id_seq TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE analytics_daily_id_seq TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE popular_pages_daily_id_seq TO nordabiz_app;
-- ============================================================
-- TRIGGER: Update analytics_daily on new session/page_view
-- ============================================================
-- Function to update daily analytics
CREATE OR REPLACE FUNCTION update_analytics_daily()
RETURNS TRIGGER AS $$
DECLARE
target_date DATE;
BEGIN
IF TG_TABLE_NAME = 'user_sessions' THEN
target_date := DATE(NEW.started_at);
ELSIF TG_TABLE_NAME = 'page_views' THEN
target_date := DATE(NEW.viewed_at);
ELSE
RETURN NEW;
END IF;
-- Insert or update daily stats
INSERT INTO analytics_daily (date, total_sessions, total_page_views, updated_at)
VALUES (target_date, 0, 0, NOW())
ON CONFLICT (date) DO NOTHING;
-- Update counts based on trigger source
IF TG_TABLE_NAME = 'user_sessions' THEN
UPDATE analytics_daily SET
total_sessions = total_sessions + 1,
unique_users = (
SELECT COUNT(DISTINCT user_id)
FROM user_sessions
WHERE DATE(started_at) = target_date AND user_id IS NOT NULL
),
anonymous_sessions = (
SELECT COUNT(*)
FROM user_sessions
WHERE DATE(started_at) = target_date AND user_id IS NULL
),
desktop_sessions = (
SELECT COUNT(*) FROM user_sessions
WHERE DATE(started_at) = target_date AND device_type = 'desktop'
),
mobile_sessions = (
SELECT COUNT(*) FROM user_sessions
WHERE DATE(started_at) = target_date AND device_type = 'mobile'
),
tablet_sessions = (
SELECT COUNT(*) FROM user_sessions
WHERE DATE(started_at) = target_date AND device_type = 'tablet'
),
updated_at = NOW()
WHERE date = target_date;
ELSIF TG_TABLE_NAME = 'page_views' THEN
UPDATE analytics_daily SET
total_page_views = total_page_views + 1,
updated_at = NOW()
WHERE date = target_date;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create triggers
DROP TRIGGER IF EXISTS trg_update_analytics_on_session ON user_sessions;
CREATE TRIGGER trg_update_analytics_on_session
AFTER INSERT ON user_sessions
FOR EACH ROW
EXECUTE FUNCTION update_analytics_daily();
DROP TRIGGER IF EXISTS trg_update_analytics_on_pageview ON page_views;
CREATE TRIGGER trg_update_analytics_on_pageview
AFTER INSERT ON page_views
FOR EACH ROW
EXECUTE FUNCTION update_analytics_daily();
-- ============================================================
-- COMMENTS
-- ============================================================
COMMENT ON TABLE user_sessions IS 'Sesje użytkowników portalu NordaBiznes';
COMMENT ON TABLE page_views IS 'Wyświetlenia stron przez użytkowników';
COMMENT ON TABLE user_clicks IS 'Kliknięcia elementów na stronach';
COMMENT ON TABLE analytics_daily IS 'Dzienne statystyki agregowane';
COMMENT ON TABLE popular_pages_daily IS 'Popularne strony - dzienne agregaty';
COMMENT ON COLUMN user_sessions.device_type IS 'Typ urządzenia: desktop, mobile, tablet';
COMMENT ON COLUMN user_sessions.duration_seconds IS 'Czas trwania sesji w sekundach';
COMMENT ON COLUMN page_views.time_on_page_seconds IS 'Czas spędzony na stronie w sekundach';
COMMENT ON COLUMN analytics_daily.bounce_rate IS 'Procent sesji z tylko 1 wyświetleniem strony';

View File

@ -0,0 +1,125 @@
/**
* NordaBiz Analytics Tracker
* Tracks user clicks, time on page, and session heartbeats
* Created: 2026-01-13
*/
(function() {
'use strict';
const HEARTBEAT_INTERVAL = 30000; // 30 seconds
const TRACK_ENDPOINT = '/api/analytics/track';
const HEARTBEAT_ENDPOINT = '/api/analytics/heartbeat';
let pageStartTime = Date.now();
let currentPageViewId = null;
// Get page view ID from meta tag (set by Flask)
function init() {
const pageViewMeta = document.querySelector('meta[name="page-view-id"]');
if (pageViewMeta && pageViewMeta.content) {
currentPageViewId = parseInt(pageViewMeta.content, 10);
}
// Start heartbeat
setInterval(sendHeartbeat, HEARTBEAT_INTERVAL);
// Track clicks
document.addEventListener('click', handleClick, true);
// Track time on page before leaving
window.addEventListener('beforeunload', handleUnload);
// Also track visibility change (tab switch)
document.addEventListener('visibilitychange', handleVisibilityChange);
}
function handleClick(e) {
// Find the closest interactive element
const target = e.target.closest('a, button, [data-track], input[type="submit"], .clickable, .company-card, nav a');
if (!target) return;
const data = {
type: 'click',
page_view_id: currentPageViewId,
element_type: getElementType(target, e),
element_id: target.id || null,
element_text: (target.textContent || '').trim().substring(0, 100) || null,
element_class: target.className || null,
target_url: target.href || target.closest('a')?.href || null,
x: e.clientX,
y: e.clientY
};
sendTracking(data);
}
function getElementType(target, e) {
// Determine element type more precisely
if (target.closest('nav')) return 'nav';
if (target.closest('.company-card')) return 'company_card';
if (target.closest('form')) return 'form';
if (target.closest('.sidebar')) return 'sidebar';
if (target.closest('.dropdown')) return 'dropdown';
if (target.closest('.modal')) return 'modal';
if (target.tagName === 'A') return 'link';
if (target.tagName === 'BUTTON') return 'button';
if (target.tagName === 'INPUT') return 'input';
if (target.hasAttribute('data-track')) return target.getAttribute('data-track');
return target.tagName.toLowerCase();
}
function handleUnload() {
if (!currentPageViewId) return;
const timeOnPage = Math.round((Date.now() - pageStartTime) / 1000);
// Use sendBeacon for reliable tracking on page exit
const data = JSON.stringify({
type: 'page_time',
page_view_id: currentPageViewId,
time_seconds: timeOnPage
});
navigator.sendBeacon(TRACK_ENDPOINT, data);
}
function handleVisibilityChange() {
if (document.visibilityState === 'hidden' && currentPageViewId) {
// Send page time when tab becomes hidden
const timeOnPage = Math.round((Date.now() - pageStartTime) / 1000);
sendTracking({
type: 'page_time',
page_view_id: currentPageViewId,
time_seconds: timeOnPage
});
}
}
function sendHeartbeat() {
fetch(HEARTBEAT_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin'
}).catch(function() {
// Silently fail
});
}
function sendTracking(data) {
fetch(TRACK_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
credentials: 'same-origin'
}).catch(function() {
// Silently fail
});
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View File

@ -0,0 +1,770 @@
{% extends "base.html" %}
{% block title %}Panel Analityki - Norda Biznes Hub{% endblock %}
{% block extra_css %}
<style>
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-xl);
flex-wrap: wrap;
gap: var(--spacing-md);
}
.admin-header h1 {
font-size: var(--font-size-3xl);
color: var(--text-primary);
}
.header-actions {
display: flex;
gap: var(--spacing-sm);
align-items: center;
}
/* 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);
}
.period-tab:hover {
background: var(--background);
}
.period-tab.active {
background: var(--primary);
color: white;
font-weight: 500;
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 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);
text-align: center;
}
.stat-icon {
width: 48px;
height: 48px;
margin: 0 auto var(--spacing-md);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.stat-icon.blue { background: #e0f2fe; color: #0284c7; }
.stat-icon.green { background: #d1fae5; color: #059669; }
.stat-icon.purple { background: #ede9fe; color: #7c3aed; }
.stat-icon.orange { background: #ffedd5; color: #ea580c; }
.stat-icon.gray { background: #f1f5f9; color: #64748b; }
.stat-number {
font-size: var(--font-size-2xl);
font-weight: 700;
display: block;
margin-bottom: var(--spacing-xs);
color: var(--text-primary);
}
.stat-label {
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
/* Two Column Layout */
.two-columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-xl);
margin-bottom: var(--spacing-xl);
}
@media (max-width: 1024px) {
.two-columns {
grid-template-columns: 1fr;
}
}
/* Section Card */
.section-card {
background: white;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.section-header {
padding: var(--spacing-md) var(--spacing-lg);
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.section-header h3 {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--text-primary);
}
.section-body {
padding: var(--spacing-lg);
}
/* Device Chart */
.device-chart {
display: flex;
justify-content: space-around;
align-items: center;
padding: var(--spacing-lg) 0;
}
.device-item {
text-align: center;
}
.device-icon {
width: 48px;
height: 48px;
margin: 0 auto var(--spacing-sm);
color: var(--text-secondary);
}
.device-count {
font-size: var(--font-size-xl);
font-weight: 600;
color: var(--text-primary);
}
.device-label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.device-percent {
font-size: var(--font-size-sm);
color: var(--primary);
font-weight: 500;
}
/* Tables */
.analytics-table {
width: 100%;
border-collapse: collapse;
}
.analytics-table th,
.analytics-table td {
padding: var(--spacing-sm) var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--border);
}
.analytics-table th {
background: var(--background);
font-weight: 600;
color: var(--text-secondary);
font-size: var(--font-size-sm);
text-transform: uppercase;
}
.analytics-table tbody tr:hover {
background: var(--background);
}
.analytics-table tbody tr:last-child td {
border-bottom: none;
}
.user-cell {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.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-sm);
font-weight: 600;
}
.user-info {
flex: 1;
min-width: 0;
}
.user-name {
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-email {
font-size: var(--font-size-sm);
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.engagement-badge {
display: inline-block;
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius);
font-size: var(--font-size-sm);
font-weight: 500;
}
.engagement-high { background: #d1fae5; color: #059669; }
.engagement-medium { background: #fef3c7; color: #d97706; }
.engagement-low { background: #fee2e2; color: #dc2626; }
/* Page path */
.page-path {
font-family: monospace;
font-size: var(--font-size-sm);
color: var(--text-primary);
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Recent Sessions */
.session-item {
display: flex;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-md);
border-bottom: 1px solid var(--border);
}
.session-item:last-child {
border-bottom: none;
}
.session-user {
flex: 1;
min-width: 0;
}
.session-user-name {
font-weight: 500;
color: var(--text-primary);
}
.session-anonymous {
color: var(--text-secondary);
font-style: italic;
}
.session-meta {
display: flex;
gap: var(--spacing-md);
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.session-time {
text-align: right;
font-size: var(--font-size-sm);
}
.session-duration {
font-weight: 500;
color: var(--text-primary);
}
.session-pages {
color: var(--text-secondary);
}
.session-device {
display: flex;
align-items: center;
gap: var(--spacing-xs);
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
/* Empty State */
.empty-state {
text-align: center;
padding: var(--spacing-2xl);
color: var(--text-secondary);
}
.empty-state svg {
width: 64px;
height: 64px;
margin-bottom: var(--spacing-md);
opacity: 0.5;
}
/* Scrollable table */
.table-scroll {
max-height: 400px;
overflow-y: auto;
}
/* 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-md);
}
.back-link:hover {
color: var(--primary);
}
/* User Detail Modal */
.user-detail-section {
background: white;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
}
.user-detail-header {
display: flex;
align-items: center;
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
padding-bottom: var(--spacing-lg);
border-bottom: 1px solid var(--border);
}
.user-detail-avatar {
width: 64px;
height: 64px;
border-radius: 50%;
background: var(--primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-xl);
font-weight: 600;
}
.user-detail-info h2 {
font-size: var(--font-size-xl);
margin-bottom: var(--spacing-xs);
}
.user-detail-info p {
color: var(--text-secondary);
}
</style>
{% endblock %}
{% block content %}
<main>
<div class="container">
<a href="{{ url_for('admin_panel') }}" class="back-link">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 12L6 8l4-4"/>
</svg>
Panel Admina
</a>
<div class="admin-header">
<div>
<h1>Analityka Uzytkownikow</h1>
</div>
<div class="header-actions">
<div class="period-tabs">
<a href="{{ url_for('admin_analytics', period='day') }}"
class="period-tab {% if current_period == 'day' %}active{% endif %}">Dzis</a>
<a href="{{ url_for('admin_analytics', period='week') }}"
class="period-tab {% if current_period == 'week' %}active{% endif %}">7 dni</a>
<a href="{{ url_for('admin_analytics', period='month') }}"
class="period-tab {% if current_period == 'month' %}active{% endif %}">30 dni</a>
<a href="{{ url_for('admin_analytics', period='all') }}"
class="period-tab {% if current_period == 'all' %}active{% endif %}">Wszystko</a>
</div>
</div>
</div>
{% if user_detail %}
<!-- User Detail Section -->
<div class="user-detail-section">
<div class="user-detail-header">
<div class="user-detail-avatar">
{{ user_detail.user.name[0]|upper if user_detail.user and user_detail.user.name else '?' }}
</div>
<div class="user-detail-info">
<h2>{{ user_detail.user.name if user_detail.user else 'Nieznany' }}</h2>
<p>{{ user_detail.user.email if user_detail.user else '' }}</p>
</div>
<a href="{{ url_for('admin_analytics', period=current_period) }}" class="btn btn-secondary">
Zamknij
</a>
</div>
<h3 style="margin-bottom: var(--spacing-md);">Ostatnie sesje</h3>
<div class="table-scroll">
<table class="analytics-table">
<thead>
<tr>
<th>Data</th>
<th>Czas trwania</th>
<th>Strony</th>
<th>Klikniecia</th>
<th>Urzadzenie</th>
</tr>
</thead>
<tbody>
{% for s in user_detail.sessions %}
<tr>
<td>{{ s.started_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td>{{ (s.duration_seconds // 60)|default(0) }} min</td>
<td>{{ s.page_views_count|default(0) }}</td>
<td>{{ s.clicks_count|default(0) }}</td>
<td>{{ s.device_type|default('?') }} / {{ s.browser|default('?') }}</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="empty-state">Brak sesji</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<h3 style="margin: var(--spacing-lg) 0 var(--spacing-md);">Ostatnie odwiedzone strony</h3>
<div class="table-scroll">
<table class="analytics-table">
<thead>
<tr>
<th>Sciezka</th>
<th>Czas</th>
<th>Data</th>
</tr>
</thead>
<tbody>
{% for p in user_detail.pages %}
<tr>
<td class="page-path">{{ p.path }}</td>
<td>{{ (p.time_on_page_seconds // 60)|default(0) if p.time_on_page_seconds else '-' }} min</td>
<td>{{ p.viewed_at.strftime('%Y-%m-%d %H:%M') }}</td>
</tr>
{% else %}
<tr>
<td colspan="3" class="empty-state">Brak danych</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<!-- Stats Cards -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon blue">
<svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
</div>
<span class="stat-number">{{ stats.total_sessions }}</span>
<span class="stat-label">Sesje</span>
</div>
<div class="stat-card">
<div class="stat-icon green">
<svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
</div>
<span class="stat-number">{{ stats.unique_users }}</span>
<span class="stat-label">Unikalni uzytkownicy</span>
</div>
<div class="stat-card">
<div class="stat-icon purple">
<svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/>
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>
</svg>
</div>
<span class="stat-number">{{ stats.total_page_views }}</span>
<span class="stat-label">Wysw. stron</span>
</div>
<div class="stat-card">
<div class="stat-icon orange">
<svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5"/>
</svg>
</div>
<span class="stat-number">{{ stats.total_clicks }}</span>
<span class="stat-label">Klikniecia</span>
</div>
<div class="stat-card">
<div class="stat-icon gray">
<svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
</div>
<span class="stat-number">{{ (stats.avg_duration / 60)|round(1) if stats.avg_duration else 0 }} min</span>
<span class="stat-label">Sr. czas sesji</span>
</div>
</div>
<div class="two-columns">
<!-- Device Breakdown -->
<div class="section-card">
<div class="section-header">
<h3>Urzadzenia</h3>
</div>
<div class="section-body">
<div class="device-chart">
<div class="device-item">
<div class="device-icon">
<svg width="48" height="48" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="4" y="6" width="40" height="28" rx="2"/>
<line x1="4" y1="38" x2="44" y2="38"/>
<line x1="18" y1="34" x2="30" y2="34"/>
</svg>
</div>
<div class="device-count">{{ device_stats.get('desktop', 0) }}</div>
<div class="device-label">Desktop</div>
{% set total_devices = device_stats.get('desktop', 0) + device_stats.get('mobile', 0) + device_stats.get('tablet', 0) %}
<div class="device-percent">
{{ ((device_stats.get('desktop', 0) / total_devices * 100)|round(0)|int if total_devices > 0 else 0) }}%
</div>
</div>
<div class="device-item">
<div class="device-icon">
<svg width="48" height="48" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="14" y="4" width="20" height="40" rx="2"/>
<line x1="20" y1="40" x2="28" y2="40"/>
</svg>
</div>
<div class="device-count">{{ device_stats.get('mobile', 0) }}</div>
<div class="device-label">Mobile</div>
<div class="device-percent">
{{ ((device_stats.get('mobile', 0) / total_devices * 100)|round(0)|int if total_devices > 0 else 0) }}%
</div>
</div>
<div class="device-item">
<div class="device-icon">
<svg width="48" height="48" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="6" y="8" width="36" height="28" rx="2"/>
<line x1="18" y1="40" x2="30" y2="40"/>
</svg>
</div>
<div class="device-count">{{ device_stats.get('tablet', 0) }}</div>
<div class="device-label">Tablet</div>
<div class="device-percent">
{{ ((device_stats.get('tablet', 0) / total_devices * 100)|round(0)|int if total_devices > 0 else 0) }}%
</div>
</div>
</div>
</div>
</div>
<!-- Popular Pages -->
<div class="section-card">
<div class="section-header">
<h3>Popularne strony</h3>
</div>
<div class="table-scroll">
<table class="analytics-table">
<thead>
<tr>
<th>Sciezka</th>
<th>Wysw.</th>
<th>Unik.</th>
</tr>
</thead>
<tbody>
{% for page in popular_pages[:10] %}
<tr>
<td class="page-path" title="{{ page.path }}">{{ page.path }}</td>
<td>{{ page.views }}</td>
<td>{{ page.unique_users }}</td>
</tr>
{% else %}
<tr>
<td colspan="3" class="empty-state">Brak danych</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- User Rankings -->
<div class="section-card" style="margin-bottom: var(--spacing-xl);">
<div class="section-header">
<h3>Ranking uzytkownikow wg aktywnosci</h3>
</div>
<div class="table-scroll">
<table class="analytics-table">
<thead>
<tr>
<th>Uzytkownik</th>
<th>Sesje</th>
<th>Strony</th>
<th>Klikniecia</th>
<th>Czas</th>
<th>Zaangazowanie</th>
<th></th>
</tr>
</thead>
<tbody>
{% for user in user_rankings %}
<tr>
<td>
<div class="user-cell">
<div class="user-avatar">{{ user.name[0]|upper if user.name else '?' }}</div>
<div class="user-info">
<div class="user-name">{{ user.name }}</div>
<div class="user-email">{{ user.email }}</div>
</div>
</div>
</td>
<td>{{ user.sessions }}</td>
<td>{{ user.page_views|default(0) }}</td>
<td>{{ user.clicks|default(0) }}</td>
<td>{{ ((user.total_time or 0) / 60)|round(0)|int }} min</td>
<td>
{% set engagement = (user.page_views|default(0)) + (user.clicks|default(0)) * 2 + ((user.total_time or 0) / 120)|int %}
{% if engagement >= 50 %}
<span class="engagement-badge engagement-high">Wysoki ({{ engagement }})</span>
{% elif engagement >= 20 %}
<span class="engagement-badge engagement-medium">Sredni ({{ engagement }})</span>
{% else %}
<span class="engagement-badge engagement-low">Niski ({{ engagement }})</span>
{% endif %}
</td>
<td>
<a href="{{ url_for('admin_analytics', period=current_period, user_id=user.id) }}"
style="color: var(--primary); text-decoration: none;">
Szczegoly
</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="7" class="empty-state">
<svg width="64" height="64" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="32" cy="32" r="28"/>
<path d="M24 28h16"/>
<path d="M24 36h8"/>
</svg>
<p>Brak danych o uzytkownikach w wybranym okresie</p>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Recent Sessions -->
<div class="section-card">
<div class="section-header">
<h3>Ostatnie sesje</h3>
</div>
<div class="table-scroll" style="max-height: 500px;">
{% for s in recent_sessions %}
<div class="session-item">
<div class="session-user">
{% if s.user %}
<div class="session-user-name">{{ s.user.name }}</div>
<div style="font-size: var(--font-size-sm); color: var(--text-secondary);">{{ s.user.email }}</div>
{% else %}
<div class="session-anonymous">Niezalogowany</div>
{% endif %}
</div>
<div class="session-device">
{% if s.device_type == 'mobile' %}
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<rect x="4" y="1" width="8" height="14" rx="1"/>
</svg>
{% elif s.device_type == 'tablet' %}
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="2" width="12" height="12" rx="1"/>
</svg>
{% else %}
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<rect x="1" y="2" width="14" height="10" rx="1"/>
<line x1="4" y1="14" x2="12" y2="14"/>
</svg>
{% endif %}
{{ s.browser|default('?') }}
</div>
<div class="session-time">
<div class="session-duration">{{ (s.duration_seconds // 60)|default(0) }} min</div>
<div class="session-pages">{{ s.page_views_count|default(0) }} stron</div>
</div>
<div style="font-size: var(--font-size-sm); color: var(--text-secondary); min-width: 100px; text-align: right;">
{{ s.started_at.strftime('%d.%m %H:%M') }}
</div>
</div>
{% else %}
<div class="empty-state">
<svg width="64" height="64" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="32" cy="32" r="28"/>
<path d="M32 20v12"/>
<path d="M32 40h.01"/>
</svg>
<p>Brak sesji w wybranym okresie</p>
</div>
{% endfor %}
</div>
</div>
</div>
</main>
{% endblock %}

View File

@ -6,6 +6,7 @@
<meta name="description" content="Norda Biznes Hub - katalog firm członkowskich stowarzyszenia Norda Biznes">
<meta name="author" content="Norda Biznes">
<meta name="robots" content="index, follow">
<meta name="page-view-id" content="{{ page_view_id|default('') }}">
<title>{% block title %}Norda Biznes Hub{% endblock %}</title>
@ -892,6 +893,9 @@
{% block extra_css %}{% endblock %}
</style>
<!-- Analytics Tracker -->
<script src="{{ url_for('static', filename='js/analytics-tracker.js') }}" defer></script>
</head>
<body>
<!-- Header -->