From ba884e516a1f21cf2fdfa87f2314f93fc5a47738 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Fri, 29 Aug 2025 10:09:37 +0200 Subject: [PATCH] fix: resolve empty analytics charts by correcting template block name - Change analytics dashboard template from `{% block scripts %}` to `{% block extra_js %}` - Fixes block name mismatch between base.html and analytics dashboard template - Resolves issue where AnalyticsDashboard JavaScript class was not loading - Charts now properly initialize and display data from API endpoints - Maintains all existing functionality while fixing the rendering issue --- ANALYTICS_FEATURE.md | 165 +++++ app/__init__.py | 2 + app/routes/analytics.py | 330 ++++++++++ app/static/mobile.css | 41 ++ app/templates/analytics/dashboard.html | 593 ++++++++++++++++++ app/templates/analytics/mobile_dashboard.html | 516 +++++++++++++++ app/templates/base.html | 15 +- templates/reports/index.html | 18 + tests/test_analytics.py | 209 ++++++ 9 files changed, 1885 insertions(+), 4 deletions(-) create mode 100644 ANALYTICS_FEATURE.md create mode 100644 app/routes/analytics.py create mode 100644 app/templates/analytics/dashboard.html create mode 100644 app/templates/analytics/mobile_dashboard.html create mode 100644 tests/test_analytics.py diff --git a/ANALYTICS_FEATURE.md b/ANALYTICS_FEATURE.md new file mode 100644 index 0000000..c9bb053 --- /dev/null +++ b/ANALYTICS_FEATURE.md @@ -0,0 +1,165 @@ +# Visual Analytics Feature + +## Overview + +The Visual Analytics feature provides interactive charts and graphs for better data visualization in the TimeTracker application. This feature enhances the existing reporting system by offering visual insights into time tracking data. + +## Features + +### 📊 Chart Types + +1. **Daily Hours Trend** - Line chart showing hours worked per day over a selected time period +2. **Billable vs Non-Billable** - Doughnut chart showing the breakdown of billable and non-billable hours +3. **Hours by Project** - Bar chart displaying total hours spent on each project +4. **Weekly Trends** - Line chart showing weekly hour patterns +5. **Hours by Time of Day** - Line chart showing when during the day work is typically performed +6. **Project Efficiency** - Dual-axis bar chart comparing hours vs revenue for projects +7. **User Performance** - Bar chart showing hours worked per user (admin only) + +### 🎛️ Interactive Controls + +- **Time Range Selector**: Choose between 7, 30, 90, or 365 days +- **Refresh Button**: Manually refresh all charts +- **Responsive Design**: Charts automatically resize for different screen sizes +- **Mobile Optimization**: Dedicated mobile template for better mobile experience + +### 📱 Mobile Experience + +- Optimized for touch devices +- Simplified layout for small screens +- Responsive chart sizing +- Touch-friendly controls + +## Technical Implementation + +### Backend + +- **New Route**: `/analytics` - Main analytics dashboard +- **API Endpoints**: RESTful API endpoints for chart data +- **Data Aggregation**: SQL queries with proper user permission filtering +- **Performance**: Efficient database queries with proper indexing + +### Frontend + +- **Chart.js 4.4.0**: Modern, lightweight charting library +- **Responsive Design**: Bootstrap 5 grid system +- **JavaScript Classes**: Modular, maintainable code structure +- **Error Handling**: Graceful error handling with user notifications + +### API Endpoints + +``` +GET /api/analytics/hours-by-day?days={days} +GET /api/analytics/hours-by-project?days={days} +GET /api/analytics/hours-by-user?days={days} +GET /api/analytics/hours-by-hour?days={days} +GET /api/analytics/billable-vs-nonbillable?days={days} +GET /api/analytics/weekly-trends?weeks={weeks} +GET /api/analytics/project-efficiency?days={days} +``` + +## Installation + +1. **Dependencies**: Chart.js is loaded from CDN (no additional npm packages required) +2. **Database**: No new database migrations required +3. **Configuration**: No additional configuration needed + +## Usage + +### Accessing Analytics + +1. Navigate to the main navigation menu +2. Click on "Analytics" (new menu item) +3. View the interactive dashboard with various charts + +### Using the Dashboard + +1. **Select Time Range**: Use the dropdown to choose your preferred time period +2. **View Charts**: Each chart provides different insights into your data +3. **Refresh Data**: Click the refresh button to update all charts +4. **Mobile View**: Automatically detects mobile devices and serves optimized template + +### Chart Interactions + +- **Hover**: Hover over chart elements to see detailed information +- **Zoom**: Some charts support zooming and panning +- **Responsive**: Charts automatically resize when the browser window changes + +## Security + +- **Authentication Required**: All analytics endpoints require user login +- **Permission Filtering**: Data is filtered based on user permissions +- **Admin Features**: User performance charts are only visible to admin users +- **Data Isolation**: Users can only see their own data (unless admin) + +## Performance Considerations + +- **Efficient Queries**: Database queries are optimized with proper joins and filtering +- **Caching**: Chart data is fetched on-demand, not pre-cached +- **Lazy Loading**: Charts are loaded asynchronously for better performance +- **Mobile Optimization**: Reduced chart complexity on mobile devices + +## Customization + +### Adding New Charts + +1. Create new API endpoint in `app/routes/analytics.py` +2. Add chart HTML to the dashboard template +3. Implement chart loading logic in the JavaScript controller +4. Add any necessary CSS styling + +### Modifying Chart Styles + +- Chart colors and styles are defined in the JavaScript code +- Global Chart.js defaults are configured in the dashboard +- CSS classes can be added for additional styling + +### Time Periods + +- Default time periods can be modified in the HTML templates +- API endpoints accept custom day/week parameters +- New time period options can be easily added + +## Browser Support + +- **Modern Browsers**: Chrome, Firefox, Safari, Edge (latest versions) +- **Mobile Browsers**: iOS Safari, Chrome Mobile, Samsung Internet +- **Chart.js**: Supports IE11+ (with polyfills) + +## Troubleshooting + +### Common Issues + +1. **Charts Not Loading**: Check browser console for JavaScript errors +2. **Data Not Displaying**: Verify user permissions and database connectivity +3. **Mobile Display Issues**: Ensure mobile template is being served correctly +4. **Performance Issues**: Check database query performance and network latency + +### Debug Mode + +- Enable browser developer tools to see console logs +- Check network tab for API request/response details +- Verify Chart.js library is loading correctly + +## Future Enhancements + +- **Export Charts**: Save charts as images or PDFs +- **Custom Dashboards**: User-configurable chart layouts +- **Real-time Updates**: Live chart updates via WebSocket +- **Advanced Filtering**: More granular data filtering options +- **Chart Annotations**: Add notes and highlights to charts +- **Data Drill-down**: Click to explore detailed data views + +## Contributing + +When adding new charts or modifying existing ones: + +1. Follow the existing code structure and patterns +2. Ensure mobile responsiveness +3. Add proper error handling +4. Update documentation +5. Test on both desktop and mobile devices + +## License + +This feature is part of the TimeTracker application and follows the same licensing terms. diff --git a/app/__init__.py b/app/__init__.py index 5b274a3..27f7db4 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -92,6 +92,7 @@ def create_app(config=None): from app.routes.reports import reports_bp from app.routes.admin import admin_bp from app.routes.api import api_bp + from app.routes.analytics import analytics_bp app.register_blueprint(auth_bp) app.register_blueprint(main_bp) @@ -100,6 +101,7 @@ def create_app(config=None): app.register_blueprint(reports_bp) app.register_blueprint(admin_bp) app.register_blueprint(api_bp) + app.register_blueprint(analytics_bp) # Register error handlers from app.utils.error_handlers import register_error_handlers diff --git a/app/routes/analytics.py b/app/routes/analytics.py new file mode 100644 index 0000000..20fce61 --- /dev/null +++ b/app/routes/analytics.py @@ -0,0 +1,330 @@ +from flask import Blueprint, render_template, request, jsonify +from flask_login import login_required, current_user +from app import db +from app.models import User, Project, TimeEntry, Settings +from datetime import datetime, timedelta +from sqlalchemy import func, extract +import calendar + +analytics_bp = Blueprint('analytics', __name__) + +@analytics_bp.route('/analytics') +@login_required +def analytics_dashboard(): + """Main analytics dashboard with charts""" + # Check if user agent indicates mobile device + user_agent = request.headers.get('User-Agent', '').lower() + is_mobile = any(device in user_agent for device in ['mobile', 'android', 'iphone', 'ipad']) + + if is_mobile: + return render_template('analytics/mobile_dashboard.html') + else: + return render_template('analytics/dashboard.html') + +@analytics_bp.route('/api/analytics/hours-by-day') +@login_required +def hours_by_day(): + """Get hours worked per day for the last 30 days""" + days = int(request.args.get('days', 30)) + end_date = datetime.now().date() + start_date = end_date - timedelta(days=days) + + # Build query based on user permissions + query = db.session.query( + func.date(TimeEntry.start_time).label('date'), + func.sum(TimeEntry.duration_seconds).label('total_seconds') + ).filter( + TimeEntry.end_time.isnot(None), + TimeEntry.start_time >= start_date, + TimeEntry.start_time <= end_date + ) + + if not current_user.is_admin: + query = query.filter(TimeEntry.user_id == current_user.id) + + results = query.group_by(func.date(TimeEntry.start_time)).all() + + # Create date range and fill missing dates with 0 + date_data = {} + current_date = start_date + while current_date <= end_date: + date_data[current_date.strftime('%Y-%m-%d')] = 0 + current_date += timedelta(days=1) + + # Fill in actual data + for date_str, total_seconds in results: + if date_str: + date_data[date_str.strftime('%Y-%m-%d')] = round(total_seconds / 3600, 2) + + return jsonify({ + 'labels': list(date_data.keys()), + 'datasets': [{ + 'label': 'Hours Worked', + 'data': list(date_data.values()), + 'borderColor': '#3b82f6', + 'backgroundColor': 'rgba(59, 130, 246, 0.1)', + 'tension': 0.4, + 'fill': True + }] + }) + +@analytics_bp.route('/api/analytics/hours-by-project') +@login_required +def hours_by_project(): + """Get total hours per project""" + days = int(request.args.get('days', 30)) + end_date = datetime.now().date() + start_date = end_date - timedelta(days=days) + + query = db.session.query( + Project.name, + func.sum(TimeEntry.duration_seconds).label('total_seconds') + ).join(TimeEntry).filter( + TimeEntry.end_time.isnot(None), + TimeEntry.start_time >= start_date, + TimeEntry.start_time <= end_date, + Project.status == 'active' + ) + + if not current_user.is_admin: + query = query.filter(TimeEntry.user_id == current_user.id) + + results = query.group_by(Project.name).order_by(func.sum(TimeEntry.duration_seconds).desc()).limit(10).all() + + labels = [project for project, _ in results] + data = [round(seconds / 3600, 2) for _, seconds in results] + + # Generate colors for each project + colors = [ + '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', + '#06b6d4', '#84cc16', '#f97316', '#ec4899', '#6366f1' + ] + + return jsonify({ + 'labels': labels, + 'datasets': [{ + 'label': 'Hours', + 'data': data, + 'backgroundColor': colors[:len(labels)], + 'borderColor': colors[:len(labels)], + 'borderWidth': 1 + }] + }) + +@analytics_bp.route('/api/analytics/hours-by-user') +@login_required +def hours_by_user(): + """Get total hours per user (admin only)""" + if not current_user.is_admin: + return jsonify({'error': 'Unauthorized'}), 403 + + days = int(request.args.get('days', 30)) + end_date = datetime.now().date() + start_date = end_date - timedelta(days=days) + + results = db.session.query( + User.username, + func.sum(TimeEntry.duration_seconds).label('total_seconds') + ).join(TimeEntry).filter( + TimeEntry.end_time.isnot(None), + TimeEntry.start_time >= start_date, + TimeEntry.start_time <= end_date, + User.is_active == True + ).group_by(User.username).order_by(func.sum(TimeEntry.duration_seconds).desc()).all() + + labels = [username for username, _ in results] + data = [round(seconds / 3600, 2) for _, seconds in results] + + return jsonify({ + 'labels': labels, + 'datasets': [{ + 'label': 'Hours', + 'data': data, + 'backgroundColor': 'rgba(59, 130, 246, 0.8)', + 'borderColor': '#3b82f6', + 'borderWidth': 2 + }] + }) + +@analytics_bp.route('/api/analytics/hours-by-hour') +@login_required +def hours_by_hour(): + """Get hours worked by hour of day (24-hour format)""" + days = int(request.args.get('days', 30)) + end_date = datetime.now().date() + start_date = end_date - timedelta(days=days) + + query = db.session.query( + extract('hour', TimeEntry.start_time).label('hour'), + func.sum(TimeEntry.duration_seconds).label('total_seconds') + ).filter( + TimeEntry.end_time.isnot(None), + TimeEntry.start_time >= start_date, + TimeEntry.start_time <= end_date + ) + + if not current_user.is_admin: + query = query.filter(TimeEntry.user_id == current_user.id) + + results = query.group_by(extract('hour', TimeEntry.start_time)).order_by(extract('hour', TimeEntry.start_time)).all() + + # Create 24-hour array + hours_data = [0] * 24 + for hour, total_seconds in results: + hours_data[int(hour)] = round(total_seconds / 3600, 2) + + labels = [f"{hour:02d}:00" for hour in range(24)] + + return jsonify({ + 'labels': labels, + 'datasets': [{ + 'label': 'Hours Worked', + 'data': hours_data, + 'backgroundColor': 'rgba(16, 185, 129, 0.8)', + 'borderColor': '#10b981', + 'borderWidth': 2, + 'tension': 0.4 + }] + }) + +@analytics_bp.route('/api/analytics/billable-vs-nonbillable') +@login_required +def billable_vs_nonbillable(): + """Get billable vs non-billable hours breakdown""" + days = int(request.args.get('days', 30)) + end_date = datetime.now().date() + start_date = end_date - timedelta(days=days) + + query = db.session.query( + TimeEntry.billable, + func.sum(TimeEntry.duration_seconds).label('total_seconds') + ).filter( + TimeEntry.end_time.isnot(None), + TimeEntry.start_time >= start_date, + TimeEntry.start_time <= end_date + ) + + if not current_user.is_admin: + query = query.filter(TimeEntry.user_id == current_user.id) + + results = query.group_by(TimeEntry.billable).all() + + billable_hours = 0 + nonbillable_hours = 0 + + for billable, total_seconds in results: + hours = round(total_seconds / 3600, 2) + if billable: + billable_hours = hours + else: + nonbillable_hours = hours + + return jsonify({ + 'labels': ['Billable', 'Non-Billable'], + 'datasets': [{ + 'label': 'Hours', + 'data': [billable_hours, nonbillable_hours], + 'backgroundColor': ['#10b981', '#6b7280'], + 'borderColor': ['#059669', '#4b5563'], + 'borderWidth': 2 + }] + }) + +@analytics_bp.route('/api/analytics/weekly-trends') +@login_required +def weekly_trends(): + """Get weekly trends over the last 12 weeks""" + weeks = int(request.args.get('weeks', 12)) + end_date = datetime.now().date() + start_date = end_date - timedelta(weeks=weeks) + + query = db.session.query( + func.date_trunc('week', TimeEntry.start_time).label('week'), + func.sum(TimeEntry.duration_seconds).label('total_seconds') + ).filter( + TimeEntry.end_time.isnot(None), + TimeEntry.start_time >= start_date, + TimeEntry.start_time <= end_date + ) + + if not current_user.is_admin: + query = query.filter(TimeEntry.user_id == current_user.id) + + results = query.group_by(func.date_trunc('week', TimeEntry.start_time)).order_by(func.date_trunc('week', TimeEntry.start_time)).all() + + labels = [] + data = [] + + for week, total_seconds in results: + if week: + # Format week as "MMM DD" (e.g., "Jan 01") + week_date = week.date() + labels.append(week_date.strftime('%b %d')) + data.append(round(total_seconds / 3600, 2)) + + return jsonify({ + 'labels': labels, + 'datasets': [{ + 'label': 'Weekly Hours', + 'data': data, + 'borderColor': '#8b5cf6', + 'backgroundColor': 'rgba(139, 92, 246, 0.1)', + 'tension': 0.4, + 'fill': True, + 'pointBackgroundColor': '#8b5cf6', + 'pointBorderColor': '#ffffff', + 'pointBorderWidth': 2 + }] + }) + +@analytics_bp.route('/api/analytics/project-efficiency') +@login_required +def project_efficiency(): + """Get project efficiency metrics (hours vs billable amount)""" + days = int(request.args.get('days', 30)) + end_date = datetime.now().date() + start_date = end_date - timedelta(days=days) + + query = db.session.query( + Project.name, + func.sum(TimeEntry.duration_seconds).label('total_seconds'), + Project.hourly_rate + ).join(TimeEntry).filter( + TimeEntry.end_time.isnot(None), + TimeEntry.start_time >= start_date, + TimeEntry.start_time <= end_date, + Project.status == 'active', + Project.billable == True, + Project.hourly_rate.isnot(None) + ) + + if not current_user.is_admin: + query = query.filter(TimeEntry.user_id == current_user.id) + + results = query.group_by(Project.name, Project.hourly_rate).order_by(func.sum(TimeEntry.duration_seconds).desc()).limit(8).all() + + labels = [project for project, _, _ in results] + hours_data = [round(seconds / 3600, 2) for _, seconds, _ in results] + revenue_data = [round((seconds / 3600) * float(rate), 2) for _, seconds, rate in results] + + return jsonify({ + 'labels': labels, + 'datasets': [ + { + 'label': 'Hours', + 'data': hours_data, + 'backgroundColor': 'rgba(59, 130, 246, 0.8)', + 'borderColor': '#3b82f6', + 'borderWidth': 2, + 'yAxisID': 'y' + }, + { + 'label': 'Revenue', + 'data': revenue_data, + 'backgroundColor': 'rgba(16, 185, 129, 0.8)', + 'borderColor': '#10b981', + 'borderWidth': 2, + 'yAxisID': 'y1' + } + ] + }) diff --git a/app/static/mobile.css b/app/static/mobile.css index f06c171..619e925 100644 --- a/app/static/mobile.css +++ b/app/static/mobile.css @@ -556,3 +556,44 @@ border: 1px solid #000 !important; } } + +/* Analytics Dashboard Mobile Styles */ +@media (max-width: 768px) { + .chart-container { + min-height: 250px !important; + } + + .analytics-card { + margin-bottom: 1rem; + } + + .analytics-summary-card { + padding: 0.75rem; + } + + .analytics-summary-card h6 { + font-size: 1.1rem; + margin-bottom: 0.25rem; + } + + .analytics-summary-card small { + font-size: 0.75rem; + } + + .analytics-summary-card i { + font-size: 1.25rem !important; + margin-bottom: 0.5rem; + } +} + +/* Chart.js Mobile Optimizations */ +@media (max-width: 768px) { + .chartjs-tooltip { + font-size: 0.875rem !important; + padding: 0.5rem !important; + } + + .chartjs-legend { + font-size: 0.875rem !important; + } +} diff --git a/app/templates/analytics/dashboard.html b/app/templates/analytics/dashboard.html new file mode 100644 index 0000000..c549124 --- /dev/null +++ b/app/templates/analytics/dashboard.html @@ -0,0 +1,593 @@ +{% extends "base.html" %} + +{% block title %}Analytics Dashboard - {{ app_name }}{% endblock %} + +{% block content %} +
+
+
+
+

+ Analytics Dashboard +

+
+ + +
+
+
+
+ + +
+
+
+
+ +

-

+

Total Hours

+
+
+
+
+
+
+ +

-

+

Billable Hours

+
+
+
+
+
+
+ +

-

+

Active Projects

+
+
+
+
+
+
+ +

-

+

Avg Daily Hours

+
+
+
+
+ + +
+
+
+
+
+ Daily Hours Trend +
+
+
+
+ +
+
+
+
+
+
+
+
+ Billable vs Non-Billable +
+
+
+
+ +
+
+
+
+
+ + +
+
+
+
+
+ Hours by Project +
+
+
+
+ +
+
+
+
+
+
+
+
+ Weekly Trends +
+
+
+
+ +
+
+
+
+
+ + +
+
+
+
+
+ Hours by Time of Day +
+
+
+
+ +
+
+
+
+
+
+
+
+ Project Efficiency +
+
+
+
+ +
+
+
+
+
+ + + {% if current_user.is_admin %} +
+
+
+
+
+ User Performance +
+
+
+
+ +
+
+
+
+
+ {% endif %} +
+ + + +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/templates/analytics/mobile_dashboard.html b/app/templates/analytics/mobile_dashboard.html new file mode 100644 index 0000000..a31bd66 --- /dev/null +++ b/app/templates/analytics/mobile_dashboard.html @@ -0,0 +1,516 @@ +{% extends "base.html" %} + +{% block title %}Analytics - {{ app_name }}{% endblock %} + +{% block content %} +
+
+
+
+

+ Analytics +

+
+ + +
+
+
+
+ + +
+
+
+
+ +
-
+ Total Hours +
+
+
+
+
+
+ +
-
+ Billable +
+
+
+
+
+
+ +
-
+ Projects +
+
+
+
+
+
+ +
-
+ Daily Avg +
+
+
+
+ + +
+
+
+
+
+ Daily Hours +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ Billable vs Non-Billable +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ Top Projects +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ Hours by Time of Day +
+
+
+
+ +
+
+
+
+ + {% if current_user.is_admin %} +
+
+
+
+ User Performance +
+
+
+
+ +
+
+
+
+ {% endif %} +
+
+ + + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index 69a49a9..6538af8 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -18,6 +18,8 @@ + +