mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-05 03:01:13 -06:00
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
This commit is contained in:
165
ANALYTICS_FEATURE.md
Normal file
165
ANALYTICS_FEATURE.md
Normal file
@@ -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.
|
||||
@@ -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
|
||||
|
||||
330
app/routes/analytics.py
Normal file
330
app/routes/analytics.py
Normal file
@@ -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'
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
593
app/templates/analytics/dashboard.html
Normal file
593
app/templates/analytics/dashboard.html
Normal file
@@ -0,0 +1,593 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Analytics Dashboard - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="fas fa-chart-line text-primary"></i> Analytics Dashboard
|
||||
</h1>
|
||||
<div class="d-flex gap-2">
|
||||
<select id="timeRange" class="form-select form-select-sm" style="width: auto;">
|
||||
<option value="7">Last 7 days</option>
|
||||
<option value="30" selected>Last 30 days</option>
|
||||
<option value="90">Last 90 days</option>
|
||||
<option value="365">Last year</option>
|
||||
</select>
|
||||
<button id="refreshCharts" class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-sync-alt"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card text-center h-100">
|
||||
<div class="card-body">
|
||||
<i class="fas fa-clock fa-2x text-primary mb-2"></i>
|
||||
<h4 class="text-primary" id="totalHours">-</h4>
|
||||
<p class="text-muted mb-0">Total Hours</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card text-center h-100">
|
||||
<div class="card-body">
|
||||
<i class="fas fa-dollar-sign fa-2x text-success mb-2"></i>
|
||||
<h4 class="text-success" id="billableHours">-</h4>
|
||||
<p class="text-muted mb-0">Billable Hours</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card text-center h-100">
|
||||
<div class="card-body">
|
||||
<i class="fas fa-project-diagram fa-2x text-info mb-2"></i>
|
||||
<h4 class="text-info" id="activeProjects">-</h4>
|
||||
<p class="text-muted mb-0">Active Projects</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card text-center h-100">
|
||||
<div class="card-body">
|
||||
<i class="fas fa-chart-line fa-2x text-warning mb-2"></i>
|
||||
<h4 class="text-warning" id="avgDailyHours">-</h4>
|
||||
<p class="text-muted mb-0">Avg Daily Hours</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row 1 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-lg-8 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-chart-area"></i> Daily Hours Trend
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container" style="position: relative; height: 300px;">
|
||||
<canvas id="dailyHoursChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-chart-pie"></i> Billable vs Non-Billable
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container" style="position: relative; height: 300px;">
|
||||
<canvas id="billableChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row 2 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-chart-bar"></i> Hours by Project
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container" style="position: relative; height: 300px;">
|
||||
<canvas id="projectChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-chart-line"></i> Weekly Trends
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container" style="position: relative; height: 300px;">
|
||||
<canvas id="weeklyTrendsChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row 3 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-clock"></i> Hours by Time of Day
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container" style="position: relative; height: 300px;">
|
||||
<canvas id="hourlyChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-chart-bar"></i> Project Efficiency
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container" style="position: relative; height: 300px;">
|
||||
<canvas id="efficiencyChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Performance Chart (Admin Only) -->
|
||||
{% if current_user.is_admin %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-users"></i> User Performance
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container" style="position: relative; height: 300px;">
|
||||
<canvas id="userChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Loading Spinner -->
|
||||
<div id="loadingSpinner" class="position-fixed top-50 start-50 translate-middle" style="display: none; z-index: 9999;">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Global chart instances
|
||||
let charts = {};
|
||||
|
||||
// Chart.js global defaults
|
||||
Chart.defaults.font.family = "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif";
|
||||
Chart.defaults.font.size = 12;
|
||||
Chart.defaults.color = '#64748b';
|
||||
Chart.defaults.plugins.legend.position = 'bottom';
|
||||
Chart.defaults.plugins.legend.labels.usePointStyle = true;
|
||||
Chart.defaults.plugins.legend.labels.padding = 20;
|
||||
|
||||
// Analytics Dashboard Controller
|
||||
class AnalyticsDashboard {
|
||||
constructor() {
|
||||
this.timeRange = 30;
|
||||
this.charts = {};
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.bindEvents();
|
||||
this.loadCharts();
|
||||
this.updateSummaryCards();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
document.getElementById('timeRange').addEventListener('change', (e) => {
|
||||
this.timeRange = parseInt(e.target.value);
|
||||
this.refreshAllCharts();
|
||||
});
|
||||
|
||||
document.getElementById('refreshCharts').addEventListener('click', () => {
|
||||
this.refreshAllCharts();
|
||||
});
|
||||
}
|
||||
|
||||
showLoading() {
|
||||
document.getElementById('loadingSpinner').style.display = 'block';
|
||||
}
|
||||
|
||||
hideLoading() {
|
||||
document.getElementById('loadingSpinner').style.display = 'none';
|
||||
}
|
||||
|
||||
async loadCharts() {
|
||||
this.showLoading();
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
this.loadDailyHoursChart(),
|
||||
this.loadBillableChart(),
|
||||
this.loadProjectChart(),
|
||||
this.loadWeeklyTrendsChart(),
|
||||
this.loadHourlyChart(),
|
||||
this.loadEfficiencyChart(),
|
||||
{% if current_user.is_admin %}
|
||||
this.loadUserChart(),
|
||||
{% endif %}
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Error loading charts:', error);
|
||||
this.showError('Failed to load charts. Please try again.');
|
||||
} finally {
|
||||
this.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
async refreshAllCharts() {
|
||||
this.showLoading();
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
this.loadDailyHoursChart(true),
|
||||
this.loadBillableChart(true),
|
||||
this.loadProjectChart(true),
|
||||
this.loadWeeklyTrendsChart(true),
|
||||
this.loadHourlyChart(true),
|
||||
this.loadEfficiencyChart(true),
|
||||
{% if current_user.is_admin %}
|
||||
this.loadUserChart(true),
|
||||
{% endif %}
|
||||
]);
|
||||
this.updateSummaryCards();
|
||||
} catch (error) {
|
||||
console.error('Error refreshing charts:', error);
|
||||
this.showError('Failed to refresh charts. Please try again.');
|
||||
} finally {
|
||||
this.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
async loadDailyHoursChart(refresh = false) {
|
||||
const response = await fetch(`/api/analytics/hours-by-day?days=${this.timeRange}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (refresh && this.charts.dailyHours) {
|
||||
this.charts.dailyHours.destroy();
|
||||
}
|
||||
|
||||
const ctx = document.getElementById('dailyHoursChart').getContext('2d');
|
||||
this.charts.dailyHours = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: data,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Hours'
|
||||
}
|
||||
},
|
||||
x: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Date'
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadBillableChart(refresh = false) {
|
||||
const response = await fetch(`/api/analytics/billable-vs-nonbillable?days=${this.timeRange}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (refresh && this.charts.billable) {
|
||||
this.charts.billable.destroy();
|
||||
}
|
||||
|
||||
const ctx = document.getElementById('billableChart').getContext('2d');
|
||||
this.charts.billable = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: data,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadProjectChart(refresh = false) {
|
||||
const response = await fetch(`/api/analytics/hours-by-project?days=${this.timeRange}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (refresh && this.charts.project) {
|
||||
this.charts.project.destroy();
|
||||
}
|
||||
|
||||
const ctx = document.getElementById('projectChart').getContext('2d');
|
||||
this.charts.project = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: data,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Hours'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadWeeklyTrendsChart(refresh = false) {
|
||||
const response = await fetch(`/api/analytics/weekly-trends?weeks=${Math.min(12, Math.ceil(this.timeRange / 7))}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (refresh && this.charts.weeklyTrends) {
|
||||
this.charts.weeklyTrends.destroy();
|
||||
}
|
||||
|
||||
const ctx = document.getElementById('weeklyTrendsChart').getContext('2d');
|
||||
this.charts.weeklyTrends = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: data,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Hours'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadHourlyChart(refresh = false) {
|
||||
const response = await fetch(`/api/analytics/hours-by-hour?days=${this.timeRange}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (refresh && this.charts.hourly) {
|
||||
this.charts.hourly.destroy();
|
||||
}
|
||||
|
||||
const ctx = document.getElementById('hourlyChart').getContext('2d');
|
||||
this.charts.hourly = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: data,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Hours'
|
||||
}
|
||||
},
|
||||
x: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Hour of Day'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadEfficiencyChart(refresh = false) {
|
||||
const response = await fetch(`/api/analytics/project-efficiency?days=${this.timeRange}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (refresh && this.charts.efficiency) {
|
||||
this.charts.efficiency.destroy();
|
||||
}
|
||||
|
||||
const ctx = document.getElementById('efficiencyChart').getContext('2d');
|
||||
this.charts.efficiency = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: data,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom'
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Hours'
|
||||
}
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Revenue'
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
{% if current_user.is_admin %}
|
||||
async loadUserChart(refresh = false) {
|
||||
const response = await fetch(`/api/analytics/hours-by-user?days=${this.timeRange}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (refresh && this.charts.user) {
|
||||
this.charts.user.destroy();
|
||||
}
|
||||
|
||||
const ctx = document.getElementById('userChart').getContext('2d');
|
||||
this.charts.user = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: data,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Hours'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
async updateSummaryCards() {
|
||||
try {
|
||||
// Get summary data from daily hours chart
|
||||
const response = await fetch(`/api/analytics/hours-by-day?days=${this.timeRange}`);
|
||||
const data = await response.json();
|
||||
|
||||
const totalHours = data.datasets[0].data.reduce((sum, hours) => sum + hours, 0);
|
||||
const avgDailyHours = totalHours / data.datasets[0].data.length;
|
||||
|
||||
document.getElementById('totalHours').textContent = totalHours.toFixed(1) + 'h';
|
||||
document.getElementById('avgDailyHours').textContent = avgDailyHours.toFixed(1) + 'h';
|
||||
|
||||
// Get billable data
|
||||
const billableResponse = await fetch(`/api/analytics/billable-vs-nonbillable?days=${this.timeRange}`);
|
||||
const billableData = await billableResponse.json();
|
||||
const billableHours = billableData.datasets[0].data[0];
|
||||
document.getElementById('billableHours').textContent = billableHours.toFixed(1) + 'h';
|
||||
|
||||
// Get project count
|
||||
const projectResponse = await fetch(`/api/analytics/hours-by-project?days=${this.timeRange}`);
|
||||
const projectData = await projectResponse.json();
|
||||
document.getElementById('activeProjects').textContent = projectData.labels.length;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating summary cards:', error);
|
||||
}
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
// Create a simple error notification
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'alert alert-danger alert-dismissible fade show position-fixed';
|
||||
errorDiv.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
|
||||
errorDiv.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
document.body.appendChild(errorDiv);
|
||||
|
||||
// Auto-remove after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (errorDiv.parentNode) {
|
||||
errorDiv.parentNode.removeChild(errorDiv);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize dashboard when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new AnalyticsDashboard();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
516
app/templates/analytics/mobile_dashboard.html
Normal file
516
app/templates/analytics/mobile_dashboard.html
Normal file
@@ -0,0 +1,516 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Analytics - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid px-3">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h1 class="h4 mb-0">
|
||||
<i class="fas fa-chart-line text-primary"></i> Analytics
|
||||
</h1>
|
||||
<div class="d-flex gap-2">
|
||||
<select id="timeRange" class="form-select form-select-sm" style="width: auto; font-size: 0.875rem;">
|
||||
<option value="7">7d</option>
|
||||
<option value="30" selected>30d</option>
|
||||
<option value="90">90d</option>
|
||||
</select>
|
||||
<button id="refreshCharts" class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Cards - Mobile Stacked -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-6 mb-2">
|
||||
<div class="card text-center h-100">
|
||||
<div class="card-body py-2">
|
||||
<i class="fas fa-clock fa-lg text-primary mb-1"></i>
|
||||
<h6 class="text-primary mb-0" id="totalHours">-</h6>
|
||||
<small class="text-muted">Total Hours</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 mb-2">
|
||||
<div class="card text-center h-100">
|
||||
<div class="card-body py-2">
|
||||
<i class="fas fa-dollar-sign fa-lg text-success mb-1"></i>
|
||||
<h6 class="text-success mb-0" id="billableHours">-</h6>
|
||||
<small class="text-muted">Billable</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 mb-2">
|
||||
<div class="card text-center h-100">
|
||||
<div class="card-body py-2">
|
||||
<i class="fas fa-project-diagram fa-lg text-info mb-1"></i>
|
||||
<h6 class="text-info mb-0" id="activeProjects">-</h6>
|
||||
<small class="text-muted">Projects</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 mb-2">
|
||||
<div class="card text-center h-100">
|
||||
<div class="card-body py-2">
|
||||
<i class="fas fa-chart-line fa-lg text-warning mb-1"></i>
|
||||
<h6 class="text-warning mb-0" id="avgDailyHours">-</h6>
|
||||
<small class="text-muted">Daily Avg</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts - Mobile Stacked -->
|
||||
<div class="row">
|
||||
<div class="col-12 mb-3">
|
||||
<div class="card">
|
||||
<div class="card-header py-2">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-chart-area"></i> Daily Hours
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container" style="position: relative; height: 250px;">
|
||||
<canvas id="dailyHoursChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 mb-3">
|
||||
<div class="card">
|
||||
<div class="card-header py-2">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-chart-pie"></i> Billable vs Non-Billable
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container" style="position: relative; height: 250px;">
|
||||
<canvas id="billableChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 mb-3">
|
||||
<div class="card">
|
||||
<div class="card-header py-2">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-chart-bar"></i> Top Projects
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container" style="position: relative; height: 250px;">
|
||||
<canvas id="projectChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 mb-3">
|
||||
<div class="card">
|
||||
<div class="card-header py-2">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-clock"></i> Hours by Time of Day
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container" style="position: relative; height: 250px;">
|
||||
<canvas id="hourlyChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if current_user.is_admin %}
|
||||
<div class="col-12 mb-3">
|
||||
<div class="card">
|
||||
<div class="card-header py-2">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-users"></i> User Performance
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container" style="position: relative; height: 250px;">
|
||||
<canvas id="userChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading Spinner -->
|
||||
<div id="loadingSpinner" class="position-fixed top-50 start-50 translate-middle" style="display: none; z-index: 9999;">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// Mobile-optimized chart defaults
|
||||
Chart.defaults.font.family = "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif";
|
||||
Chart.defaults.font.size = 11;
|
||||
Chart.defaults.color = '#64748b';
|
||||
Chart.defaults.plugins.legend.position = 'bottom';
|
||||
Chart.defaults.plugins.legend.labels.usePointStyle = true;
|
||||
Chart.defaults.plugins.legend.labels.padding = 15;
|
||||
Chart.defaults.plugins.legend.labels.boxWidth = 12;
|
||||
|
||||
// Mobile Analytics Dashboard Controller
|
||||
class MobileAnalyticsDashboard {
|
||||
constructor() {
|
||||
this.timeRange = 30;
|
||||
this.charts = {};
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.bindEvents();
|
||||
this.loadCharts();
|
||||
this.updateSummaryCards();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
document.getElementById('timeRange').addEventListener('change', (e) => {
|
||||
this.timeRange = parseInt(e.target.value);
|
||||
this.refreshAllCharts();
|
||||
});
|
||||
|
||||
document.getElementById('refreshCharts').addEventListener('click', () => {
|
||||
this.refreshAllCharts();
|
||||
});
|
||||
}
|
||||
|
||||
showLoading() {
|
||||
document.getElementById('loadingSpinner').style.display = 'block';
|
||||
}
|
||||
|
||||
hideLoading() {
|
||||
document.getElementById('loadingSpinner').style.display = 'none';
|
||||
}
|
||||
|
||||
async loadCharts() {
|
||||
this.showLoading();
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
this.loadDailyHoursChart(),
|
||||
this.loadBillableChart(),
|
||||
this.loadProjectChart(),
|
||||
this.loadHourlyChart(),
|
||||
{% if current_user.is_admin %}
|
||||
this.loadUserChart(),
|
||||
{% endif %}
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Error loading charts:', error);
|
||||
this.showError('Failed to load charts. Please try again.');
|
||||
} finally {
|
||||
this.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
async refreshAllCharts() {
|
||||
this.showLoading();
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
this.loadDailyHoursChart(true),
|
||||
this.loadBillableChart(true),
|
||||
this.loadProjectChart(true),
|
||||
this.loadHourlyChart(true),
|
||||
{% if current_user.is_admin %}
|
||||
this.loadUserChart(true),
|
||||
{% endif %}
|
||||
]);
|
||||
this.updateSummaryCards();
|
||||
} catch (error) {
|
||||
console.error('Error refreshing charts:', error);
|
||||
this.showError('Failed to refresh charts. Please try again.');
|
||||
} finally {
|
||||
this.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
async loadDailyHoursChart(refresh = false) {
|
||||
const response = await fetch(`/api/analytics/hours-by-day?days=${this.timeRange}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (refresh && this.charts.dailyHours) {
|
||||
this.charts.dailyHours.destroy();
|
||||
}
|
||||
|
||||
const ctx = document.getElementById('dailyHoursChart').getContext('2d');
|
||||
this.charts.dailyHours = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: data,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: false
|
||||
},
|
||||
ticks: {
|
||||
font: {
|
||||
size: 10
|
||||
}
|
||||
}
|
||||
},
|
||||
x: {
|
||||
title: {
|
||||
display: false
|
||||
},
|
||||
ticks: {
|
||||
font: {
|
||||
size: 10
|
||||
},
|
||||
maxRotation: 45
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadBillableChart(refresh = false) {
|
||||
const response = await fetch(`/api/analytics/billable-vs-nonbillable?days=${this.timeRange}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (refresh && this.charts.billable) {
|
||||
this.charts.billable.destroy();
|
||||
}
|
||||
|
||||
const ctx = document.getElementById('billableChart').getContext('2d');
|
||||
this.charts.billable = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: data,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
font: {
|
||||
size: 10
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadProjectChart(refresh = false) {
|
||||
const response = await fetch(`/api/analytics/hours-by-project?days=${this.timeRange}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (refresh && this.charts.project) {
|
||||
this.charts.project.destroy();
|
||||
}
|
||||
|
||||
const ctx = document.getElementById('projectChart').getContext('2d');
|
||||
this.charts.project = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: data,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: false
|
||||
},
|
||||
ticks: {
|
||||
font: {
|
||||
size: 10
|
||||
}
|
||||
}
|
||||
},
|
||||
x: {
|
||||
ticks: {
|
||||
font: {
|
||||
size: 10
|
||||
},
|
||||
maxRotation: 45
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadHourlyChart(refresh = false) {
|
||||
const response = await fetch(`/api/analytics/hours-by-hour?days=${this.timeRange}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (refresh && this.charts.hourly) {
|
||||
this.charts.hourly.destroy();
|
||||
}
|
||||
|
||||
const ctx = document.getElementById('hourlyChart').getContext('2d');
|
||||
this.charts.hourly = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: data,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: false
|
||||
},
|
||||
ticks: {
|
||||
font: {
|
||||
size: 10
|
||||
}
|
||||
}
|
||||
},
|
||||
x: {
|
||||
title: {
|
||||
display: false
|
||||
},
|
||||
ticks: {
|
||||
font: {
|
||||
size: 10
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
{% if current_user.is_admin %}
|
||||
async loadUserChart(refresh = false) {
|
||||
const response = await fetch(`/api/analytics/hours-by-user?days=${this.timeRange}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (refresh && this.charts.user) {
|
||||
this.charts.user.destroy();
|
||||
}
|
||||
|
||||
const ctx = document.getElementById('userChart').getContext('2d');
|
||||
this.charts.user = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: data,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: false
|
||||
},
|
||||
ticks: {
|
||||
font: {
|
||||
size: 10
|
||||
}
|
||||
}
|
||||
},
|
||||
x: {
|
||||
ticks: {
|
||||
font: {
|
||||
size: 10
|
||||
},
|
||||
maxRotation: 45
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
async updateSummaryCards() {
|
||||
try {
|
||||
// Get summary data from daily hours chart
|
||||
const response = await fetch(`/api/analytics/hours-by-day?days=${this.timeRange}`);
|
||||
const data = await response.json();
|
||||
|
||||
const totalHours = data.datasets[0].data.reduce((sum, hours) => sum + hours, 0);
|
||||
const avgDailyHours = totalHours / data.datasets[0].data.length;
|
||||
|
||||
document.getElementById('totalHours').textContent = totalHours.toFixed(1);
|
||||
document.getElementById('avgDailyHours').textContent = avgDailyHours.toFixed(1);
|
||||
|
||||
// Get billable data
|
||||
const billableResponse = await fetch(`/api/analytics/billable-vs-nonbillable?days=${this.timeRange}`);
|
||||
const billableData = await billableResponse.json();
|
||||
const billableHours = billableData.datasets[0].data[0];
|
||||
document.getElementById('billableHours').textContent = billableHours.toFixed(1);
|
||||
|
||||
// Get project count
|
||||
const projectResponse = await fetch(`/api/analytics/hours-by-project?days=${this.timeRange}`);
|
||||
const projectData = await projectResponse.json();
|
||||
document.getElementById('activeProjects').textContent = projectData.labels.length;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating summary cards:', error);
|
||||
}
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
// Create a simple error notification
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'alert alert-danger alert-dismissible fade show position-fixed';
|
||||
errorDiv.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 280px; max-width: 90vw;';
|
||||
errorDiv.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
document.body.appendChild(errorDiv);
|
||||
|
||||
// Auto-remove after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (errorDiv.parentNode) {
|
||||
errorDiv.parentNode.removeChild(errorDiv);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize dashboard when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new MobileAnalyticsDashboard();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -18,6 +18,8 @@
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<!-- Google Fonts -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<!-- Chart.js -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
|
||||
<!-- Custom CSS -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='mobile.css') }}">
|
||||
<style>
|
||||
@@ -1039,23 +1041,28 @@
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if 'projects.' in request.endpoint %}active{% endif %}" href="{{ url_for('projects.list_projects') }}">
|
||||
<a class="nav-link {% if request.endpoint and 'projects.' in request.endpoint %}active{% endif %}" href="{{ url_for('projects.list_projects') }}">
|
||||
<i class="fas fa-project-diagram me-1"></i>Projects
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if 'timer.' in request.endpoint %}active{% endif %}" href="{{ url_for('timer.manual_entry') }}">
|
||||
<a class="nav-link {% if request.endpoint and 'timer.' in request.endpoint %}active{% endif %}" href="{{ url_for('timer.manual_entry') }}">
|
||||
<i class="fas fa-plus me-1"></i>Log Time
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if 'reports.' in request.endpoint %}active{% endif %}" href="{{ url_for('reports.reports') }}">
|
||||
<a class="nav-link {% if request.endpoint and 'reports.' in request.endpoint %}active{% endif %}" href="{{ url_for('reports.reports') }}">
|
||||
<i class="fas fa-chart-bar me-1"></i>Reports
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint and 'analytics.' in request.endpoint %}active{% endif %}" href="{{ url_for('analytics.analytics_dashboard') }}">
|
||||
<i class="fas fa-chart-line me-1"></i>Analytics
|
||||
</a>
|
||||
</li>
|
||||
{% if current_user.is_admin %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if 'admin.' in request.endpoint %}active{% endif %}" href="{{ url_for('admin.admin_dashboard') }}">
|
||||
<a class="nav-link {% if request.endpoint and 'admin.' in request.endpoint %}active{% endif %}" href="{{ url_for('admin.admin_dashboard') }}">
|
||||
<i class="fas fa-cog me-1"></i>Admin
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -115,6 +115,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-chart-line"></i> Visual Analytics
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">Interactive charts and graphs for better data visualization and insights.</p>
|
||||
<div class="d-grid">
|
||||
<a href="{{ url_for('analytics.analytics_dashboard') }}" class="btn btn-primary">
|
||||
<i class="fas fa-chart-area"></i> View Analytics
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
|
||||
209
tests/test_analytics.py
Normal file
209
tests/test_analytics.py
Normal file
@@ -0,0 +1,209 @@
|
||||
import pytest
|
||||
from app import create_app, db
|
||||
from app.models import User, Project, TimeEntry
|
||||
from datetime import datetime, timedelta
|
||||
from flask_login import login_user
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
app = create_app()
|
||||
app.config['TESTING'] = True
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
yield app
|
||||
db.drop_all()
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
return app.test_client()
|
||||
|
||||
@pytest.fixture
|
||||
def sample_data(app):
|
||||
with app.app_context():
|
||||
# Create test user
|
||||
user = User(username='testuser', role='user')
|
||||
user.is_active = True
|
||||
db.session.add(user)
|
||||
|
||||
# Create test project
|
||||
project = Project(name='Test Project', client='Test Client')
|
||||
db.session.add(project)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Create test time entries
|
||||
base_time = datetime.now() - timedelta(days=5)
|
||||
for i in range(5):
|
||||
entry = TimeEntry(
|
||||
user_id=user.id,
|
||||
project_id=project.id,
|
||||
start_time=base_time + timedelta(days=i),
|
||||
end_time=base_time + timedelta(days=i, hours=8),
|
||||
duration_seconds=28800, # 8 hours
|
||||
billable=True
|
||||
)
|
||||
db.session.add(entry)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return {'user': user, 'project': project}
|
||||
|
||||
def test_analytics_dashboard_requires_login(client):
|
||||
"""Test that analytics dashboard requires authentication"""
|
||||
response = client.get('/analytics')
|
||||
assert response.status_code == 302 # Redirect to login
|
||||
|
||||
def test_analytics_dashboard_accessible_when_logged_in(client, app, sample_data):
|
||||
"""Test that analytics dashboard is accessible when logged in"""
|
||||
with app.app_context():
|
||||
with client.session_transaction() as sess:
|
||||
# Simulate login
|
||||
user = sample_data['user']
|
||||
login_user(user)
|
||||
sess['_user_id'] = user.id
|
||||
|
||||
response = client.get('/analytics')
|
||||
assert response.status_code == 200
|
||||
assert b'Analytics Dashboard' in response.data
|
||||
|
||||
def test_hours_by_day_api(client, app, sample_data):
|
||||
"""Test hours by day API endpoint"""
|
||||
with app.app_context():
|
||||
with client.session_transaction() as sess:
|
||||
user = sample_data['user']
|
||||
login_user(user)
|
||||
sess['_user_id'] = user.id
|
||||
|
||||
response = client.get('/api/analytics/hours-by-day?days=7')
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.get_json()
|
||||
assert 'labels' in data
|
||||
assert 'datasets' in data
|
||||
assert len(data['datasets']) > 0
|
||||
|
||||
def test_hours_by_project_api(client, app, sample_data):
|
||||
"""Test hours by project API endpoint"""
|
||||
with app.app_context():
|
||||
with client.session_transaction() as sess:
|
||||
user = sample_data['user']
|
||||
login_user(user)
|
||||
sess['_user_id'] = user.id
|
||||
|
||||
response = client.get('/api/analytics/hours-by-project?days=7')
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.get_json()
|
||||
assert 'labels' in data
|
||||
assert 'datasets' in data
|
||||
assert len(data['labels']) > 0
|
||||
|
||||
def test_billable_vs_nonbillable_api(client, app, sample_data):
|
||||
"""Test billable vs non-billable API endpoint"""
|
||||
with app.app_context():
|
||||
with client.session_transaction() as sess:
|
||||
user = sample_data['user']
|
||||
login_user(user)
|
||||
sess['_user_id'] = user.id
|
||||
|
||||
response = client.get('/api/analytics/billable-vs-nonbillable?days=7')
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.get_json()
|
||||
assert 'labels' in data
|
||||
assert 'datasets' in data
|
||||
assert len(data['labels']) == 2 # Billable and Non-Billable
|
||||
|
||||
def test_hours_by_hour_api(client, app, sample_data):
|
||||
"""Test hours by hour API endpoint"""
|
||||
with app.app_context():
|
||||
with client.session_transaction() as sess:
|
||||
user = sample_data['user']
|
||||
login_user(user)
|
||||
sess['_user_id'] = user.id
|
||||
|
||||
response = client.get('/api/analytics/hours-by-hour?days=7')
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.get_json()
|
||||
assert 'labels' in data
|
||||
assert 'datasets' in data
|
||||
assert len(data['labels']) == 24 # 24 hours
|
||||
|
||||
def test_weekly_trends_api(client, app, sample_data):
|
||||
"""Test weekly trends API endpoint"""
|
||||
with app.app_context():
|
||||
with client.session_transaction() as sess:
|
||||
user = sample_data['user']
|
||||
login_user(user)
|
||||
sess['_user_id'] = user.id
|
||||
|
||||
response = client.get('/api/analytics/weekly-trends?weeks=4')
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.get_json()
|
||||
assert 'labels' in data
|
||||
assert 'datasets' in data
|
||||
|
||||
def test_project_efficiency_api(client, app, sample_data):
|
||||
"""Test project efficiency API endpoint"""
|
||||
with app.app_context():
|
||||
with client.session_transaction() as sess:
|
||||
user = sample_data['user']
|
||||
login_user(user)
|
||||
sess['_user_id'] = user.id
|
||||
|
||||
response = client.get('/api/analytics/project-efficiency?days=7')
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.get_json()
|
||||
assert 'labels' in data
|
||||
assert 'datasets' in data
|
||||
|
||||
def test_user_performance_api_requires_admin(client, app, sample_data):
|
||||
"""Test that user performance API requires admin access"""
|
||||
with app.app_context():
|
||||
with client.session_transaction() as sess:
|
||||
user = sample_data['user']
|
||||
login_user(user)
|
||||
sess['_user_id'] = user.id
|
||||
|
||||
response = client.get('/api/analytics/hours-by-user?days=7')
|
||||
assert response.status_code == 403 # Forbidden for non-admin users
|
||||
|
||||
def test_user_performance_api_accessible_by_admin(client, app, sample_data):
|
||||
"""Test that user performance API is accessible by admin users"""
|
||||
with app.app_context():
|
||||
# Make user admin
|
||||
user = sample_data['user']
|
||||
user.role = 'admin'
|
||||
db.session.commit()
|
||||
|
||||
with client.session_transaction() as sess:
|
||||
login_user(user)
|
||||
sess['_user_id'] = user.id
|
||||
|
||||
response = client.get('/api/analytics/hours-by-user?days=7')
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.get_json()
|
||||
assert 'labels' in data
|
||||
assert 'datasets' in data
|
||||
|
||||
def test_api_endpoints_with_invalid_parameters(client, app, sample_data):
|
||||
"""Test API endpoints with invalid parameters"""
|
||||
with app.app_context():
|
||||
with client.session_transaction() as sess:
|
||||
user = sample_data['user']
|
||||
login_user(user)
|
||||
sess['_user_id'] = user.id
|
||||
|
||||
# Test with invalid days parameter
|
||||
response = client.get('/api/analytics/hours-by-day?days=invalid')
|
||||
assert response.status_code == 500 # Should handle invalid parameter gracefully
|
||||
|
||||
# Test with missing parameter (should use default)
|
||||
response = client.get('/api/analytics/hours-by-day')
|
||||
assert response.status_code == 200
|
||||
Reference in New Issue
Block a user