Files
TimeTracker/app/routes/analytics.py
T
Dries Peeters b4486a627f fix: CI tests, code quality, and duplicate DB indexes
- Webhook models: remove duplicate index definitions so db.create_all()
  no longer raises 'index already exists' (columns already have index=True)
- ImportService: fix circular import by late-importing ClientService,
  ProjectService, TimeTrackingService in __init__
- reports: fix F823 by renaming unpack variable _ to _entry_count to avoid
  shadowing gettext _ in export_task_excel()
- Code quality: add .flake8 with extend-ignore so flake8 CI passes;
  simplify pyproject.toml isort config (drop unsupported options)
- Format: run black and isort on app/
- tests: restore minimal app fixture in test_import_export_models
2026-03-15 10:51:52 +01:00

1356 lines
47 KiB
Python

import calendar
from datetime import datetime, timedelta
from flask import Blueprint, jsonify, render_template, request
from flask_login import current_user, login_required
from sqlalchemy import case, extract, func
from app import db
from app.models import Invoice, Payment, Project, Settings, Task, TimeEntry, User
from app.utils.module_helpers import module_enabled
analytics_bp = Blueprint("analytics", __name__)
@analytics_bp.route("/analytics")
@login_required
@module_enabled("analytics")
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"])
# Check for legacy/simple dashboard query parameter
use_legacy = request.args.get("legacy", "").lower() == "true"
if is_mobile:
return render_template("analytics/mobile_dashboard.html")
elif use_legacy:
return render_template("analytics/dashboard.html")
else:
return render_template("analytics/dashboard_improved.html")
@analytics_bp.route("/api/analytics/hours-by-day")
@login_required
@module_enabled("analytics")
def hours_by_day():
"""Get hours worked per day for the last 30 days"""
try:
days = int(request.args.get("days", 30))
except (ValueError, TypeError):
return jsonify({"error": "Invalid days parameter"}), 400
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:
# Handle both string and date object returns from different databases
if isinstance(date_str, str):
formatted_date = date_str
elif hasattr(date_str, "strftime"):
formatted_date = date_str.strftime("%Y-%m-%d")
else:
# Skip if we can't format the date
continue
if total_seconds is None:
total_seconds = 0
date_data[formatted_date] = 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-forecast")
@login_required
@module_enabled("analytics")
def hours_forecast():
"""Get forecasted hours for the next 7 days using moving average (7-day window)"""
try:
days = int(request.args.get("days", 30))
forecast_days = min(int(request.args.get("forecast_days", 7)), 14)
except (ValueError, TypeError):
return jsonify({"error": "Invalid parameters"}), 400
end_date = datetime.now().date()
start_date = end_date - timedelta(days=days)
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)).order_by(func.date(TimeEntry.start_time)).all()
date_data = {}
current = start_date
while current <= end_date:
date_data[current.strftime("%Y-%m-%d")] = 0
current += timedelta(days=1)
for date_str, total_seconds in results:
if date_str:
fmt = date_str.strftime("%Y-%m-%d") if hasattr(date_str, "strftime") else str(date_str)[:10]
date_data[fmt] = round((total_seconds or 0) / 3600, 2)
values = list(date_data.values())
window = 7
if len(values) < window:
avg = sum(values) / len(values) if values else 0
else:
avg = sum(values[-window:]) / window
labels = list(date_data.keys())
forecast_labels = []
forecast_data = []
for i in range(1, forecast_days + 1):
d = end_date + timedelta(days=i)
forecast_labels.append(d.strftime("%Y-%m-%d"))
forecast_data.append(round(avg, 2))
return jsonify(
{
"historical": {"labels": labels, "data": list(date_data.values())},
"forecast": {"labels": forecast_labels, "data": forecast_data},
"avg_daily_hours": round(avg, 2),
}
)
@analytics_bp.route("/api/analytics/hours-by-project")
@login_required
@module_enabled("analytics")
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
@module_enabled("analytics")
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
@module_enabled("analytics")
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:
if total_seconds is None:
total_seconds = 0
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
@module_enabled("analytics")
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:
if total_seconds is None:
total_seconds = 0
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
@module_enabled("analytics")
def weekly_trends():
"""Get weekly trends over the last 12 weeks"""
try:
weeks = int(request.args.get("weeks", 12))
except (ValueError, TypeError):
return jsonify({"error": "Invalid weeks parameter"}), 400
end_date = datetime.now().date()
start_date = end_date - timedelta(weeks=weeks)
# Get all time entries and group by week in Python (database-agnostic)
query = db.session.query(TimeEntry.start_time, TimeEntry.duration_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.all()
# Group by week in Python
from collections import defaultdict
week_data = defaultdict(float)
for start_time, duration_seconds in results:
# Get the start of the week (Monday) for this entry
if isinstance(start_time, str):
try:
entry_date = datetime.strptime(start_time, "%Y-%m-%d %H:%M:%S").date()
except ValueError:
# Try alternative format if the first one fails
try:
entry_date = datetime.strptime(start_time, "%Y-%m-%d").date()
except ValueError:
# Skip invalid date strings
continue
elif isinstance(start_time, datetime):
entry_date = start_time.date()
elif isinstance(start_time, type(end_date)): # date object
entry_date = start_time
else:
# Skip if we can't determine the date
continue
# Ensure entry_date is a date object before calculating weekday
if not isinstance(entry_date, type(end_date)):
continue
# Calculate Monday of that week
week_start = entry_date - timedelta(days=entry_date.weekday())
week_data[week_start] += duration_seconds or 0
# Sort by week and format output
labels = []
data = []
for week_start_key in sorted(week_data.keys()):
# Ensure week_start is a date object before calling strftime
if isinstance(week_start_key, str):
# If it's a string, try to parse it
try:
week_start_date = datetime.strptime(week_start_key, "%Y-%m-%d").date()
except (ValueError, AttributeError):
continue
elif isinstance(week_start_key, type(end_date)):
week_start_date = week_start_key
else:
# Skip if it's not a date object or string
continue
labels.append(week_start_date.strftime("%b %d"))
data.append(round(week_data[week_start_key] / 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/overtime")
@login_required
@module_enabled("analytics")
def overtime_analytics():
"""Get overtime statistics for the current user or all users (if admin).
Supports period=ytd (year-to-date), or days=N (last N days), or start_date/end_date."""
from app.utils.overtime import calculate_period_overtime, get_daily_breakdown
end_date = datetime.now().date()
period = request.args.get("period", "").lower()
if period == "ytd":
start_date = end_date.replace(month=1, day=1)
else:
start_date_arg = request.args.get("start_date")
end_date_arg = request.args.get("end_date")
if start_date_arg and end_date_arg:
try:
start_date = datetime.strptime(start_date_arg, "%Y-%m-%d").date()
end_date = datetime.strptime(end_date_arg, "%Y-%m-%d").date()
except ValueError:
return jsonify({"error": "Invalid start_date or end_date"}), 400
else:
try:
days = int(request.args.get("days", 30))
except (ValueError, TypeError):
return jsonify({"error": "Invalid days parameter"}), 400
start_date = end_date - timedelta(days=days)
# If admin, show all users; otherwise show current user only
if current_user.is_admin:
users = User.query.filter_by(is_active=True).all()
else:
users = [current_user]
# Calculate overtime for each user
user_overtime_data = []
total_overtime = 0
total_regular = 0
total_undertime = 0
total_days_under = 0
for user in users:
overtime_info = calculate_period_overtime(user, start_date, end_date)
if overtime_info["total_hours"] > 0: # Only include users with tracked time
user_overtime_data.append(
{
"username": user.display_name,
"regular_hours": overtime_info["regular_hours"],
"overtime_hours": overtime_info["overtime_hours"],
"undertime_hours": overtime_info.get("undertime_hours", 0),
"days_under": overtime_info.get("days_under", 0),
"total_hours": overtime_info["total_hours"],
"days_with_overtime": overtime_info["days_with_overtime"],
}
)
total_overtime += overtime_info["overtime_hours"]
total_regular += overtime_info["regular_hours"]
total_undertime += overtime_info.get("undertime_hours", 0)
total_days_under += overtime_info.get("days_under", 0)
# Get daily breakdown for chart
if not current_user.is_admin:
daily_data = get_daily_breakdown(current_user, start_date, end_date)
else:
# For admin, show aggregated daily data
daily_data = []
return jsonify(
{
"period": "ytd" if period == "ytd" else "range",
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
"users": user_overtime_data,
"summary": {
"total_regular_hours": round(total_regular, 2),
"total_overtime_hours": round(total_overtime, 2),
"total_undertime_hours": round(total_undertime, 2),
"days_under": total_days_under,
"total_hours": round(total_regular + total_overtime, 2),
"overtime_percentage": round(
(
(total_overtime / (total_regular + total_overtime) * 100)
if (total_regular + total_overtime) > 0
else 0
),
1,
),
},
"daily_breakdown": [
{
"date": day["date_str"],
"regular_hours": day["regular_hours"],
"overtime_hours": day["overtime_hours"],
"undertime_hours": day.get("undertime_hours", 0),
"is_undertime": day.get("is_undertime", False),
"total_hours": day["total_hours"],
}
for day in daily_data
],
}
)
@analytics_bp.route("/api/analytics/project-efficiency")
@login_required
@module_enabled("analytics")
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",
},
],
}
)
@analytics_bp.route("/api/analytics/today-by-task")
@login_required
@module_enabled("analytics")
def today_by_task():
"""Get today's total hours grouped by task (includes project-level entries without task).
Optional query params:
- date: YYYY-MM-DD (defaults to today)
- user_id: admin-only override to view a specific user's data
"""
# Parse target date
date_str = request.args.get("date")
if date_str:
try:
target_date = datetime.strptime(date_str, "%Y-%m-%d").date()
except ValueError:
return jsonify({"error": "Invalid date format, expected YYYY-MM-DD"}), 400
else:
target_date = datetime.now().date()
# Base query
query = (
db.session.query(
TimeEntry.task_id,
Task.name.label("task_name"),
TimeEntry.project_id,
Project.name.label("project_name"),
func.sum(TimeEntry.duration_seconds).label("total_seconds"),
)
.join(Project, Project.id == TimeEntry.project_id)
.outerjoin(Task, Task.id == TimeEntry.task_id)
.filter(TimeEntry.end_time.isnot(None), func.date(TimeEntry.start_time) == target_date)
)
# Scope to current user unless admin (with optional override)
if not current_user.is_admin:
query = query.filter(TimeEntry.user_id == current_user.id)
else:
user_id = request.args.get("user_id", type=int)
if user_id:
query = query.filter(TimeEntry.user_id == user_id)
results = (
query.group_by(TimeEntry.task_id, Task.name, TimeEntry.project_id, Project.name)
.order_by(func.sum(TimeEntry.duration_seconds).desc())
.all()
)
rows = []
for task_id, task_name, project_id, project_name, total_seconds in results:
total_seconds = int(total_seconds or 0)
total_hours = round(total_seconds / 3600, 2)
label = f"{project_name}{task_name}" if task_name else f"{project_name} • No task"
rows.append(
{
"task_id": task_id,
"task_name": task_name,
"project_id": project_id,
"project_name": project_name,
"total_seconds": total_seconds,
"total_hours": total_hours,
"label": label,
}
)
return jsonify({"date": target_date.strftime("%Y-%m-%d"), "rows": rows})
@analytics_bp.route("/api/analytics/summary-with-comparison")
@login_required
@module_enabled("analytics")
def summary_with_comparison():
"""Get summary metrics with comparison to previous period"""
days = int(request.args.get("days", 30))
end_date = datetime.now().date()
start_date = end_date - timedelta(days=days)
# Previous period dates
prev_end_date = start_date - timedelta(days=1)
prev_start_date = prev_end_date - timedelta(days=days)
# Current period query
current_query = db.session.query(
func.sum(TimeEntry.duration_seconds).label("total_seconds"),
func.count(TimeEntry.id).label("total_entries"),
func.sum(case((TimeEntry.billable == True, TimeEntry.duration_seconds), else_=0)).label("billable_seconds"),
).filter(TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_date, TimeEntry.start_time <= end_date)
# Previous period query
prev_query = db.session.query(
func.sum(TimeEntry.duration_seconds).label("total_seconds"),
func.count(TimeEntry.id).label("total_entries"),
func.sum(case((TimeEntry.billable == True, TimeEntry.duration_seconds), else_=0)).label("billable_seconds"),
).filter(
TimeEntry.end_time.isnot(None), TimeEntry.start_time >= prev_start_date, TimeEntry.start_time <= prev_end_date
)
if not current_user.is_admin:
current_query = current_query.filter(TimeEntry.user_id == current_user.id)
prev_query = prev_query.filter(TimeEntry.user_id == current_user.id)
current_result = current_query.first()
prev_result = prev_query.first()
current_hours = round((current_result.total_seconds or 0) / 3600, 1)
prev_hours = round((prev_result.total_seconds or 0) / 3600, 1)
hours_change = ((current_hours - prev_hours) / prev_hours * 100) if prev_hours > 0 else 0
current_billable = round((current_result.billable_seconds or 0) / 3600, 1)
prev_billable = round((prev_result.billable_seconds or 0) / 3600, 1)
billable_change = ((current_billable - prev_billable) / prev_billable * 100) if prev_billable > 0 else 0
current_entries = current_result.total_entries or 0
prev_entries = prev_result.total_entries or 0
entries_change = ((current_entries - prev_entries) / prev_entries * 100) if prev_entries > 0 else 0
# Get active projects count
active_projects = Project.query.filter_by(status="active").count()
# Calculate average daily hours
avg_daily_hours = round(current_hours / days, 1) if days > 0 else 0
# Calculate billable percentage
billable_percentage = round((current_billable / current_hours * 100), 1) if current_hours > 0 else 0
# Get payment data for the period
payment_query = db.session.query(
func.sum(Payment.amount).label("total_payments"), func.count(Payment.id).label("payment_count")
).filter(Payment.payment_date >= start_date, Payment.payment_date <= end_date, Payment.status == "completed")
if not current_user.is_admin:
payment_query = (
payment_query.join(Invoice).join(Project).join(TimeEntry).filter(TimeEntry.user_id == current_user.id)
)
payment_result = payment_query.first()
total_payments = float(payment_result.total_payments or 0)
payment_count = payment_result.payment_count or 0
return jsonify(
{
"total_hours": current_hours,
"total_hours_change": round(hours_change, 1),
"billable_hours": current_billable,
"billable_hours_change": round(billable_change, 1),
"total_entries": current_entries,
"entries_change": round(entries_change, 1),
"active_projects": active_projects,
"avg_daily_hours": avg_daily_hours,
"billable_percentage": billable_percentage,
"total_payments": round(total_payments, 2),
"payment_count": payment_count,
}
)
@analytics_bp.route("/api/analytics/task-completion")
@login_required
@module_enabled("analytics")
def task_completion():
"""Get task completion analytics"""
days = int(request.args.get("days", 30))
end_date = datetime.now().date()
start_date = end_date - timedelta(days=days)
# Get tasks completed in period
completed_query = db.session.query(func.count(Task.id).label("count")).filter(
Task.status == "done", Task.completed_at >= start_date, Task.completed_at <= end_date
)
if not current_user.is_admin:
completed_query = completed_query.filter(Task.assigned_to == current_user.id)
completed_count = completed_query.scalar() or 0
# Get tasks by status
status_query = db.session.query(Task.status, func.count(Task.id).label("count")).filter(
Task.created_at >= start_date
)
if not current_user.is_admin:
status_query = status_query.filter(Task.assigned_to == current_user.id)
status_results = status_query.group_by(Task.status).all()
status_data = {"todo": 0, "in_progress": 0, "review": 0, "done": 0, "cancelled": 0}
for status, count in status_results:
if status in status_data:
status_data[status] = count
# Get task completion rate by project
project_query = (
db.session.query(
Project.name,
func.count(Task.id).label("total_tasks"),
func.sum(case((Task.status == "done", 1), else_=0)).label("completed_tasks"),
)
.join(Task)
.filter(Task.created_at >= start_date, Project.status == "active")
)
if not current_user.is_admin:
project_query = project_query.filter(Task.assigned_to == current_user.id)
project_results = project_query.group_by(Project.name).order_by(func.count(Task.id).desc()).limit(10).all()
project_labels = []
project_completion_rates = []
for project_name, total, completed in project_results:
project_labels.append(project_name)
rate = (completed / total * 100) if total > 0 else 0
project_completion_rates.append(round(rate, 1))
return jsonify(
{
"completed_count": completed_count,
"status_breakdown": status_data,
"project_labels": project_labels,
"project_completion_rates": project_completion_rates,
}
)
@analytics_bp.route("/api/analytics/revenue-metrics")
@login_required
@module_enabled("analytics")
def revenue_metrics():
"""Get revenue and financial metrics"""
days = int(request.args.get("days", 30))
end_date = datetime.now().date()
start_date = end_date - timedelta(days=days)
settings = Settings.get_settings()
currency = settings.currency
# Get billable hours with rates
query = (
db.session.query(func.sum(TimeEntry.duration_seconds).label("total_seconds"), Project.hourly_rate)
.join(Project)
.filter(
TimeEntry.end_time.isnot(None),
TimeEntry.start_time >= start_date,
TimeEntry.start_time <= end_date,
TimeEntry.billable == True,
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.hourly_rate).all()
total_revenue = 0
for seconds, rate in results:
if seconds and rate:
hours = seconds / 3600
total_revenue += hours * float(rate)
# Get billable hours
billable_query = db.session.query(func.sum(TimeEntry.duration_seconds).label("total_seconds")).filter(
TimeEntry.end_time.isnot(None),
TimeEntry.start_time >= start_date,
TimeEntry.start_time <= end_date,
TimeEntry.billable == True,
)
if not current_user.is_admin:
billable_query = billable_query.filter(TimeEntry.user_id == current_user.id)
billable_seconds = billable_query.scalar() or 0
billable_hours = round(billable_seconds / 3600, 1)
# Calculate average hourly rate
avg_hourly_rate = (total_revenue / billable_hours) if billable_hours > 0 else 0
# Get revenue by project
project_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,
TimeEntry.billable == True,
Project.billable == True,
Project.hourly_rate.isnot(None),
)
)
if not current_user.is_admin:
project_query = project_query.filter(TimeEntry.user_id == current_user.id)
project_results = (
project_query.group_by(Project.name, Project.hourly_rate)
.order_by(func.sum(TimeEntry.duration_seconds).desc())
.limit(8)
.all()
)
project_labels = []
project_revenue = []
for project_name, seconds, rate in project_results:
project_labels.append(project_name)
if seconds and rate:
revenue = (seconds / 3600) * float(rate)
project_revenue.append(round(revenue, 2))
else:
project_revenue.append(0)
return jsonify(
{
"total_revenue": round(total_revenue, 2),
"billable_hours": billable_hours,
"avg_hourly_rate": round(avg_hourly_rate, 2),
"currency": currency,
"project_labels": project_labels,
"project_revenue": project_revenue,
}
)
@analytics_bp.route("/api/analytics/insights")
@login_required
@module_enabled("analytics")
def insights():
"""Generate insights and recommendations based on analytics data"""
days = int(request.args.get("days", 30))
end_date = datetime.now().date()
start_date = end_date - timedelta(days=days)
insights_list = []
# Analyze time entries
query = db.session.query(
func.sum(TimeEntry.duration_seconds).label("total_seconds"),
func.avg(TimeEntry.duration_seconds).label("avg_seconds"),
func.count(TimeEntry.id).label("total_entries"),
func.sum(case((TimeEntry.billable == True, TimeEntry.duration_seconds), else_=0)).label("billable_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)
result = query.first()
total_hours = (result.total_seconds or 0) / 3600
billable_hours = (result.billable_seconds or 0) / 3600
avg_entry_hours = (result.avg_seconds or 0) / 3600
# Insight 1: Billable ratio
if total_hours > 0:
billable_ratio = (billable_hours / total_hours) * 100
if billable_ratio < 60:
insights_list.append(
{
"type": "warning",
"icon": "fas fa-exclamation-triangle",
"title": "Low Billable Ratio",
"message": f"Only {billable_ratio:.1f}% of your time is billable. Consider focusing on billable projects.",
}
)
elif billable_ratio > 85:
insights_list.append(
{
"type": "success",
"icon": "fas fa-trophy",
"title": "Excellent Billable Ratio",
"message": f"You have {billable_ratio:.1f}% billable time. Great work!",
}
)
# Insight 2: Average daily hours
avg_daily = total_hours / days if days > 0 else 0
if avg_daily < 4:
insights_list.append(
{
"type": "info",
"icon": "fas fa-chart-line",
"title": "Low Activity",
"message": f"Average of {avg_daily:.1f}h per day. Consider tracking more consistently.",
}
)
elif avg_daily > 10:
insights_list.append(
{
"type": "warning",
"icon": "fas fa-battery-empty",
"title": "High Workload",
"message": f"Averaging {avg_daily:.1f}h per day. Remember to take breaks!",
}
)
# Insight 3: Project diversity
project_count = db.session.query(func.count(func.distinct(TimeEntry.project_id))).filter(
TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_date, TimeEntry.start_time <= end_date
)
if not current_user.is_admin:
project_count = project_count.filter(TimeEntry.user_id == current_user.id)
num_projects = project_count.scalar() or 0
if num_projects > 8:
insights_list.append(
{
"type": "info",
"icon": "fas fa-tasks",
"title": "Multiple Projects",
"message": f"Working on {num_projects} projects. Consider consolidating focus.",
}
)
# Insight 4: Weekend work (if any)
weekend_query = db.session.query(func.sum(TimeEntry.duration_seconds).label("weekend_seconds")).filter(
TimeEntry.end_time.isnot(None),
TimeEntry.start_time >= start_date,
TimeEntry.start_time <= end_date,
extract("dow", TimeEntry.start_time).in_([0, 6]), # Sunday=0, Saturday=6
)
if not current_user.is_admin:
weekend_query = weekend_query.filter(TimeEntry.user_id == current_user.id)
weekend_seconds = weekend_query.scalar() or 0
weekend_hours = weekend_seconds / 3600
if weekend_hours > 5:
weekend_percent = (weekend_hours / total_hours * 100) if total_hours > 0 else 0
insights_list.append(
{
"type": "warning",
"icon": "fas fa-calendar-times",
"title": "Weekend Work",
"message": f"{weekend_percent:.0f}% of work done on weekends ({weekend_hours:.1f}h). Consider work-life balance.",
}
)
return jsonify({"insights": insights_list})
@analytics_bp.route("/api/analytics/payments-over-time")
@login_required
@module_enabled("analytics")
def payments_over_time():
"""Get payments over time"""
days = int(request.args.get("days", 30))
end_date = datetime.now().date()
start_date = end_date - timedelta(days=days)
# Build query
query = db.session.query(
func.date(Payment.payment_date).label("date"),
func.sum(Payment.amount).label("total_amount"),
func.count(Payment.id).label("payment_count"),
).filter(Payment.payment_date >= start_date, Payment.payment_date <= end_date)
if not current_user.is_admin:
query = (
query.join(Invoice).join(Project).join(TimeEntry).filter(TimeEntry.user_id == current_user.id).distinct()
)
results = query.group_by(func.date(Payment.payment_date)).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_obj, total_amount, _ in results:
if date_obj:
if isinstance(date_obj, str):
formatted_date = date_obj
elif hasattr(date_obj, "strftime"):
formatted_date = date_obj.strftime("%Y-%m-%d")
else:
# Skip if we can't format the date
continue
date_data[formatted_date] = float(total_amount or 0)
return jsonify(
{
"labels": list(date_data.keys()),
"datasets": [
{
"label": "Payments Received",
"data": list(date_data.values()),
"borderColor": "#10b981",
"backgroundColor": "rgba(16, 185, 129, 0.1)",
"tension": 0.4,
"fill": True,
}
],
}
)
@analytics_bp.route("/api/analytics/payments-by-status")
@login_required
@module_enabled("analytics")
def payments_by_status():
"""Get payment breakdown by status"""
days = int(request.args.get("days", 30))
end_date = datetime.now().date()
start_date = end_date - timedelta(days=days)
query = db.session.query(
Payment.status, func.count(Payment.id).label("count"), func.sum(Payment.amount).label("total_amount")
).filter(Payment.payment_date >= start_date, Payment.payment_date <= end_date)
if not current_user.is_admin:
query = (
query.join(Invoice).join(Project).join(TimeEntry).filter(TimeEntry.user_id == current_user.id).distinct()
)
results = query.group_by(Payment.status).all()
labels = []
counts = []
amounts = []
colors = {"completed": "#10b981", "pending": "#f59e0b", "failed": "#ef4444", "refunded": "#6b7280"}
background_colors = []
for status, count, amount in results:
labels.append(status.title() if status else "Unknown")
counts.append(count)
amounts.append(float(amount or 0))
background_colors.append(colors.get(status, "#3b82f6"))
return jsonify(
{
"labels": labels,
"count_dataset": {"label": "Payment Count", "data": counts, "backgroundColor": background_colors},
"amount_dataset": {"label": "Total Amount", "data": amounts, "backgroundColor": background_colors},
}
)
@analytics_bp.route("/api/analytics/payments-by-method")
@login_required
@module_enabled("analytics")
def payments_by_method():
"""Get payment breakdown by payment method"""
days = int(request.args.get("days", 30))
end_date = datetime.now().date()
start_date = end_date - timedelta(days=days)
query = db.session.query(
Payment.method, func.count(Payment.id).label("count"), func.sum(Payment.amount).label("total_amount")
).filter(Payment.payment_date >= start_date, Payment.payment_date <= end_date, Payment.method.isnot(None))
if not current_user.is_admin:
query = (
query.join(Invoice).join(Project).join(TimeEntry).filter(TimeEntry.user_id == current_user.id).distinct()
)
results = query.group_by(Payment.method).order_by(func.sum(Payment.amount).desc()).all()
labels = []
amounts = []
colors = [
"#3b82f6",
"#10b981",
"#f59e0b",
"#ef4444",
"#8b5cf6",
"#06b6d4",
"#84cc16",
"#f97316",
"#ec4899",
"#6366f1",
]
for idx, (method, _, amount) in enumerate(results):
labels.append(method.replace("_", " ").title() if method else "Other")
amounts.append(float(amount or 0))
return jsonify(
{
"labels": labels,
"datasets": [
{"label": "Amount", "data": amounts, "backgroundColor": colors[: len(labels)], "borderWidth": 2}
],
}
)
@analytics_bp.route("/api/analytics/payment-summary")
@login_required
@module_enabled("analytics")
def payment_summary():
"""Get payment summary statistics"""
days = int(request.args.get("days", 30))
end_date = datetime.now().date()
start_date = end_date - timedelta(days=days)
# Previous period
prev_end_date = start_date - timedelta(days=1)
prev_start_date = prev_end_date - timedelta(days=days)
# Current period query
current_query = db.session.query(
func.sum(Payment.amount).label("total_amount"),
func.count(Payment.id).label("payment_count"),
func.sum(Payment.gateway_fee).label("total_fees"),
func.sum(Payment.net_amount).label("total_net"),
).filter(Payment.payment_date >= start_date, Payment.payment_date <= end_date)
# Previous period query
prev_query = db.session.query(
func.sum(Payment.amount).label("total_amount"), func.count(Payment.id).label("payment_count")
).filter(Payment.payment_date >= prev_start_date, Payment.payment_date <= prev_end_date)
if not current_user.is_admin:
current_query = (
current_query.join(Invoice).join(Project).join(TimeEntry).filter(TimeEntry.user_id == current_user.id)
)
prev_query = prev_query.join(Invoice).join(Project).join(TimeEntry).filter(TimeEntry.user_id == current_user.id)
current_result = current_query.first()
prev_result = prev_query.first()
current_amount = float(current_result.total_amount or 0)
prev_amount = float(prev_result.total_amount or 0)
amount_change = ((current_amount - prev_amount) / prev_amount * 100) if prev_amount > 0 else 0
current_count = current_result.payment_count or 0
prev_count = prev_result.payment_count or 0
count_change = ((current_count - prev_count) / prev_count * 100) if prev_count > 0 else 0
total_fees = float(current_result.total_fees or 0)
total_net = float(current_result.total_net or 0)
# Get completed vs pending
status_query = db.session.query(Payment.status, func.sum(Payment.amount).label("amount")).filter(
Payment.payment_date >= start_date, Payment.payment_date <= end_date
)
if not current_user.is_admin:
status_query = (
status_query.join(Invoice).join(Project).join(TimeEntry).filter(TimeEntry.user_id == current_user.id)
)
status_results = status_query.group_by(Payment.status).all()
completed_amount = 0
pending_amount = 0
for status, amount in status_results:
if status == "completed":
completed_amount = float(amount or 0)
elif status == "pending":
pending_amount = float(amount or 0)
return jsonify(
{
"total_amount": round(current_amount, 2),
"amount_change": round(amount_change, 1),
"payment_count": current_count,
"count_change": round(count_change, 1),
"total_fees": round(total_fees, 2),
"total_net": round(total_net, 2),
"completed_amount": round(completed_amount, 2),
"pending_amount": round(pending_amount, 2),
"avg_payment": round(current_amount / current_count, 2) if current_count > 0 else 0,
}
)
@analytics_bp.route("/api/analytics/revenue-vs-payments")
@login_required
@module_enabled("analytics")
def revenue_vs_payments():
"""Compare potential revenue (from time tracking) with actual payments"""
days = int(request.args.get("days", 30))
end_date = datetime.now().date()
start_date = end_date - timedelta(days=days)
settings = Settings.get_settings()
currency = settings.currency
# Get billable revenue (potential)
revenue_query = (
db.session.query(func.sum(TimeEntry.duration_seconds).label("total_seconds"), Project.hourly_rate)
.join(Project)
.filter(
TimeEntry.end_time.isnot(None),
TimeEntry.start_time >= start_date,
TimeEntry.start_time <= end_date,
TimeEntry.billable == True,
Project.billable == True,
Project.hourly_rate.isnot(None),
)
)
if not current_user.is_admin:
revenue_query = revenue_query.filter(TimeEntry.user_id == current_user.id)
revenue_results = revenue_query.group_by(Project.hourly_rate).all()
potential_revenue = 0
for seconds, rate in revenue_results:
if seconds and rate:
hours = seconds / 3600
potential_revenue += hours * float(rate)
# Get actual payments
payment_query = db.session.query(func.sum(Payment.amount).label("total_amount")).filter(
Payment.payment_date >= start_date, Payment.payment_date <= end_date, Payment.status == "completed"
)
if not current_user.is_admin:
payment_query = (
payment_query.join(Invoice).join(Project).join(TimeEntry).filter(TimeEntry.user_id == current_user.id)
)
actual_payments = payment_query.scalar() or 0
actual_payments = float(actual_payments)
collection_rate = (actual_payments / potential_revenue * 100) if potential_revenue > 0 else 0
outstanding = potential_revenue - actual_payments
return jsonify(
{
"potential_revenue": round(potential_revenue, 2),
"actual_payments": round(actual_payments, 2),
"outstanding": round(outstanding, 2),
"collection_rate": round(collection_rate, 1),
"currency": currency,
"labels": ["Collected", "Outstanding"],
"data": [round(actual_payments, 2), round(outstanding, 2) if outstanding > 0 else 0],
}
)