Files
TimeTracker/app/utils/overtime.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

428 lines
16 KiB
Python

"""
Overtime Calculation Utilities
Provides functions to calculate overtime hours based on user's standard working hours
per day or per week (configurable via user.overtime_calculation_mode).
"""
from datetime import date, datetime, timedelta
from typing import Any, Dict, List, Optional, Tuple
def get_week_start_for_date(d: date, user: Any) -> date:
"""
Return the week-start date for a given date using the user's week_start_day.
User convention: 0=Sunday, 1=Monday, ..., 6=Saturday.
Python weekday: 0=Monday, 6=Sunday.
"""
week_start_day = getattr(user, "week_start_day", 1)
python_week_start = (week_start_day - 1) % 7
days_since = (d.weekday() - python_week_start) % 7
return d - timedelta(days=days_since)
def calculate_daily_overtime(total_hours: float, standard_hours: float) -> float:
"""
Calculate overtime hours for a single day.
Args:
total_hours: Total hours worked in a day
standard_hours: Standard working hours per day
Returns:
Overtime hours (0 if no overtime)
"""
if total_hours <= standard_hours:
return 0.0
return round(total_hours - standard_hours, 2)
def calculate_period_overtime(
user, start_date: date, end_date: date, include_weekends: Optional[bool] = None
) -> Dict[str, float]:
"""
Calculate overtime for a specific period.
Uses user.overtime_calculation_mode: 'daily' (cap per day) or 'weekly' (cap per week).
user.standard_hours_per_day is used in daily mode; user.standard_hours_per_week in weekly mode.
Args:
user: User object with standard_hours_per_day, optional overtime_include_weekends,
overtime_calculation_mode, standard_hours_per_week, week_start_day
start_date: Start date of the period
end_date: End date of the period
include_weekends: If None, use user.overtime_include_weekends; if True, weekend hours
count as regular/overtime like weekdays; if False, all weekend hours count as overtime.
Returns:
Dictionary with regular_hours, overtime_hours, undertime_hours, and total_hours
"""
mode = getattr(user, "overtime_calculation_mode", "daily") or "daily"
if mode == "weekly":
return _calculate_period_overtime_weekly(user, start_date, end_date, include_weekends)
if include_weekends is None:
include_weekends = getattr(user, "overtime_include_weekends", True)
from datetime import datetime as dt
from app.models import TimeEntry
start_datetime = dt.combine(start_date, dt.min.time())
end_datetime = dt.combine(end_date, dt.max.time())
entries = TimeEntry.query.filter(
TimeEntry.user_id == user.id,
TimeEntry.end_time.isnot(None),
TimeEntry.start_time >= start_datetime,
TimeEntry.start_time <= end_datetime,
).all()
daily_hours = {}
for entry in entries:
entry_date = entry.start_time.date()
hours = entry.duration_hours
if entry_date not in daily_hours:
daily_hours[entry_date] = 0.0
daily_hours[entry_date] += hours
standard_hours = getattr(user, "standard_hours_per_day", 8.0) or 8.0
total_regular = 0.0
total_overtime = 0.0
total_undertime = 0.0
days_under = 0
for day_date, hours in daily_hours.items():
if not include_weekends and day_date.weekday() >= 5:
total_overtime += hours
else:
if hours <= standard_hours:
total_regular += hours
undertime = max(0.0, standard_hours - hours)
if undertime > 0:
total_undertime += undertime
days_under += 1
else:
total_regular += standard_hours
total_overtime += hours - standard_hours
return {
"regular_hours": round(total_regular, 2),
"overtime_hours": round(total_overtime, 2),
"undertime_hours": round(total_undertime, 2),
"days_under": days_under,
"total_hours": round(total_regular + total_overtime, 2),
"days_with_overtime": sum(1 for h in daily_hours.values() if h > standard_hours),
}
def _calculate_period_overtime_weekly(
user, start_date: date, end_date: date, include_weekends: Optional[bool]
) -> Dict[str, float]:
"""Overtime for a period in weekly mode: group by week, cap at standard_hours_per_week."""
if include_weekends is None:
include_weekends = getattr(user, "overtime_include_weekends", True)
from datetime import datetime as dt
from app.models import TimeEntry
standard_weekly = getattr(user, "standard_hours_per_week", None)
if standard_weekly is None or standard_weekly <= 0:
standard_weekly = (getattr(user, "standard_hours_per_day", 8.0) or 8.0) * 5
start_datetime = dt.combine(start_date, dt.min.time())
end_datetime = dt.combine(end_date, dt.max.time())
entries = TimeEntry.query.filter(
TimeEntry.user_id == user.id,
TimeEntry.end_time.isnot(None),
TimeEntry.start_time >= start_datetime,
TimeEntry.start_time <= end_datetime,
).all()
# Group by week (week-start date)
weekly_hours: Dict[date, float] = {}
for entry in entries:
entry_date = entry.start_time.date()
if not include_weekends and entry_date.weekday() >= 5:
# Weekend: count as overtime in weekly total (add to week bucket but we'll treat all as overtime for that week's excess)
pass # Still add to week total; overtime is (total - standard), so weekend hours push total up
week_start = get_week_start_for_date(entry_date, user)
if week_start not in weekly_hours:
weekly_hours[week_start] = 0.0
weekly_hours[week_start] += entry.duration_hours
total_regular = 0.0
total_overtime = 0.0
total_undertime = 0.0
weeks_under = 0
weeks_with_overtime = 0
for week_start, total_week_hours in weekly_hours.items():
week_end = week_start + timedelta(days=6)
# Only full weeks entirely inside [start_date, end_date] get regular/overtime split
if week_start >= start_date and week_end <= end_date:
regular_week = min(total_week_hours, standard_weekly)
overtime_week = max(0.0, total_week_hours - standard_weekly)
undertime_week = max(0.0, standard_weekly - total_week_hours)
total_regular += regular_week
total_overtime += overtime_week
total_undertime += undertime_week
if undertime_week > 0:
weeks_under += 1
if overtime_week > 0:
weeks_with_overtime += 1
else:
# Partial week: count all as regular for simplicity (no overtime from partial weeks)
total_regular += total_week_hours
total_hours = total_regular + total_overtime
return {
"regular_hours": round(total_regular, 2),
"overtime_hours": round(total_overtime, 2),
"undertime_hours": round(total_undertime, 2),
"days_under": weeks_under, # reuse field for "weeks under" in weekly mode
"total_hours": round(total_hours, 2),
"days_with_overtime": weeks_with_overtime, # reuse for "weeks with overtime"
}
def get_daily_breakdown(user, start_date: date, end_date: date, include_weekends: Optional[bool] = None) -> List[Dict]:
"""
Get a daily breakdown of regular and overtime hours.
In weekly mode (user.overtime_calculation_mode == 'weekly'), per-day regular/overtime
are not defined; each row has total_hours and regular_hours=0, overtime_hours=0.
Use calculate_period_overtime for period-level regular/overtime in that case.
Args:
user: User object with standard_hours_per_day and optional overtime_include_weekends
start_date: Start date of the period
end_date: End date of the period
include_weekends: If None, use user.overtime_include_weekends (see calculate_period_overtime).
Returns:
List of dictionaries with daily breakdown
"""
mode = getattr(user, "overtime_calculation_mode", "daily") or "daily"
if include_weekends is None:
include_weekends = getattr(user, "overtime_include_weekends", True)
from datetime import datetime as dt
from app.models import TimeEntry
start_datetime = dt.combine(start_date, dt.min.time())
end_datetime = dt.combine(end_date, dt.max.time())
entries = (
TimeEntry.query.filter(
TimeEntry.user_id == user.id,
TimeEntry.end_time.isnot(None),
TimeEntry.start_time >= start_datetime,
TimeEntry.start_time <= end_datetime,
)
.order_by(TimeEntry.start_time)
.all()
)
daily_data = {}
for entry in entries:
entry_date = entry.start_time.date()
if entry_date not in daily_data:
daily_data[entry_date] = {"date": entry_date, "total_hours": 0.0, "entries": []}
daily_data[entry_date]["total_hours"] += entry.duration_hours
daily_data[entry_date]["entries"].append(entry)
if mode == "weekly":
# In weekly mode no per-day split; just total_hours per day
breakdown = []
for day_date in sorted(daily_data.keys()):
day_info = daily_data[day_date]
total_hours = day_info["total_hours"]
breakdown.append(
{
"date": day_date,
"date_str": day_date.strftime("%Y-%m-%d"),
"weekday": day_date.strftime("%A"),
"total_hours": round(total_hours, 2),
"regular_hours": 0.0,
"overtime_hours": 0.0,
"undertime_hours": 0.0,
"is_overtime": False,
"is_undertime": False,
"entries_count": len(day_info["entries"]),
}
)
return breakdown
standard_hours = getattr(user, "standard_hours_per_day", 8.0) or 8.0
breakdown = []
for day_date in sorted(daily_data.keys()):
day_info = daily_data[day_date]
total_hours = day_info["total_hours"]
if not include_weekends and day_date.weekday() >= 5:
regular_hours = 0.0
overtime_hours = total_hours
undertime_hours = 0.0
else:
regular_hours = min(total_hours, standard_hours)
overtime_hours = max(0, total_hours - standard_hours)
undertime_hours = max(0, standard_hours - total_hours) if total_hours < standard_hours else 0.0
is_undertime = undertime_hours > 0
breakdown.append(
{
"date": day_date,
"date_str": day_date.strftime("%Y-%m-%d"),
"weekday": day_date.strftime("%A"),
"total_hours": round(total_hours, 2),
"regular_hours": round(regular_hours, 2),
"overtime_hours": round(overtime_hours, 2),
"undertime_hours": round(undertime_hours, 2),
"is_overtime": overtime_hours > 0,
"is_undertime": is_undertime,
"entries_count": len(day_info["entries"]),
}
)
return breakdown
def get_weekly_overtime_summary(user, weeks: int = 4) -> List[Dict]:
"""
Get a weekly summary of overtime for the last N weeks.
Uses user's week_start_day for week boundaries. In weekly mode compares each
week's total to standard_hours_per_week; in daily mode uses per-day cap then sums.
"""
from app.models import TimeEntry
end_date = datetime.now().date()
start_date = end_date - timedelta(weeks=weeks)
start_datetime = datetime.combine(start_date, datetime.min.time())
end_datetime = datetime.combine(end_date, datetime.max.time())
entries = TimeEntry.query.filter(
TimeEntry.user_id == user.id,
TimeEntry.end_time.isnot(None),
TimeEntry.start_time >= start_datetime,
TimeEntry.start_time <= end_datetime,
).all()
# Group by week using user's week start
weekly_data: Dict[date, Dict[date, float]] = {}
for entry in entries:
entry_date = entry.start_time.date()
week_start = get_week_start_for_date(entry_date, user)
if week_start not in weekly_data:
weekly_data[week_start] = {}
if entry_date not in weekly_data[week_start]:
weekly_data[week_start][entry_date] = 0.0
weekly_data[week_start][entry_date] += entry.duration_hours
mode = getattr(user, "overtime_calculation_mode", "daily") or "daily"
standard_daily = getattr(user, "standard_hours_per_day", 8.0) or 8.0
standard_weekly = getattr(user, "standard_hours_per_week", None) or (standard_daily * 5)
weekly_summary = []
for week_start in sorted(weekly_data.keys()):
daily_hours = weekly_data[week_start]
week_total = sum(daily_hours.values())
if mode == "weekly":
week_regular = min(week_total, standard_weekly)
week_overtime = max(0.0, week_total - standard_weekly)
else:
week_regular = 0.0
week_overtime = 0.0
for hours in daily_hours.values():
if hours <= standard_daily:
week_regular += hours
else:
week_regular += standard_daily
week_overtime += hours - standard_daily
week_end = week_start + timedelta(days=6)
weekly_summary.append(
{
"week_start": week_start,
"week_end": week_end,
"week_label": f"{week_start.strftime('%b %d')} - {week_end.strftime('%b %d')}",
"regular_hours": round(week_regular, 2),
"overtime_hours": round(week_overtime, 2),
"total_hours": round(week_regular + week_overtime, 2),
"days_worked": len(daily_hours),
}
)
return weekly_summary
def get_overtime_ytd(user) -> Dict[str, float]:
"""
Return overtime for the current year to date (Jan 1 through today).
Uses calculate_period_overtime; no stored balance.
"""
today = datetime.now().date()
start_ytd = date(today.year, 1, 1)
return calculate_period_overtime(user, start_ytd, today)
def get_overtime_last_12_months(user) -> Dict[str, float]:
"""
Return overtime for the last 12 months (rolling).
"""
today = datetime.now().date()
start = today - timedelta(days=365)
return calculate_period_overtime(user, start, today)
def get_overtime_statistics(user, start_date: date, end_date: date) -> Dict:
"""
Get comprehensive overtime statistics for a period.
Args:
user: User object
start_date: Start date
end_date: End date
Returns:
Dictionary with various overtime statistics
"""
period_data = calculate_period_overtime(user, start_date, end_date)
daily_breakdown = get_daily_breakdown(user, start_date, end_date)
# Calculate additional statistics
days_worked = len(daily_breakdown)
days_with_overtime = sum(1 for day in daily_breakdown if day["is_overtime"])
# Average hours per day
avg_hours_per_day = period_data["total_hours"] / days_worked if days_worked > 0 else 0
# Max overtime in a single day
max_overtime_day = max(
(day for day in daily_breakdown if day["is_overtime"]), key=lambda x: x["overtime_hours"], default=None
)
return {
"period": {
"start_date": start_date.strftime("%Y-%m-%d"),
"end_date": end_date.strftime("%Y-%m-%d"),
"days_in_period": (end_date - start_date).days + 1,
},
"hours": period_data,
"days_statistics": {
"days_worked": days_worked,
"days_with_overtime": days_with_overtime,
"percentage_overtime_days": (round(days_with_overtime / days_worked * 100, 1) if days_worked > 0 else 0),
},
"averages": {
"avg_hours_per_day": round(avg_hours_per_day, 2),
"avg_overtime_per_overtime_day": (
round(period_data["overtime_hours"] / days_with_overtime, 2) if days_with_overtime > 0 else 0
),
},
"max_overtime": {
"date": max_overtime_day["date_str"] if max_overtime_day else None,
"hours": max_overtime_day["overtime_hours"] if max_overtime_day else 0,
},
}