mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-24 05:29:27 -06:00
- Normalize line endings from CRLF to LF across all files to match .editorconfig - Standardize quote style from single quotes to double quotes - Normalize whitespace and formatting throughout codebase - Apply consistent code style across 372 files including: * Application code (models, routes, services, utils) * Test files * Configuration files * CI/CD workflows This ensures consistency with the project's .editorconfig settings and improves code maintainability.
295 lines
9.3 KiB
Python
295 lines
9.3 KiB
Python
"""
|
|
Overtime Calculation Utilities
|
|
|
|
Provides functions to calculate overtime hours based on user's standard working hours per day.
|
|
"""
|
|
|
|
from datetime import datetime, timedelta, date
|
|
from typing import Dict, List, Optional, Tuple
|
|
from sqlalchemy import func
|
|
|
|
|
|
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: bool = True
|
|
) -> Dict[str, float]:
|
|
"""
|
|
Calculate overtime for a specific period.
|
|
|
|
Args:
|
|
user: User object with standard_hours_per_day setting
|
|
start_date: Start date of the period
|
|
end_date: End date of the period
|
|
include_weekends: Whether to count weekend hours as overtime
|
|
|
|
Returns:
|
|
Dictionary with regular_hours, overtime_hours, and total_hours
|
|
"""
|
|
from app.models import TimeEntry
|
|
from app import db
|
|
|
|
# Get all time entries for the period
|
|
# Convert dates to datetime ranges to include full day
|
|
from datetime import datetime as dt
|
|
|
|
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 entries by date
|
|
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
|
|
|
|
# Calculate overtime per day
|
|
standard_hours = user.standard_hours_per_day
|
|
total_regular = 0.0
|
|
total_overtime = 0.0
|
|
|
|
for day_date, hours in daily_hours.items():
|
|
# Check if weekend
|
|
if not include_weekends and day_date.weekday() >= 5: # Saturday=5, Sunday=6
|
|
# All weekend hours are overtime
|
|
total_overtime += hours
|
|
else:
|
|
# Calculate regular vs overtime
|
|
if hours <= standard_hours:
|
|
total_regular += hours
|
|
else:
|
|
total_regular += standard_hours
|
|
total_overtime += hours - standard_hours
|
|
|
|
return {
|
|
"regular_hours": round(total_regular, 2),
|
|
"overtime_hours": round(total_overtime, 2),
|
|
"total_hours": round(total_regular + total_overtime, 2),
|
|
"days_with_overtime": sum(1 for h in daily_hours.values() if h > standard_hours),
|
|
}
|
|
|
|
|
|
def get_daily_breakdown(user, start_date: date, end_date: date) -> List[Dict]:
|
|
"""
|
|
Get a daily breakdown of regular and overtime hours.
|
|
|
|
Args:
|
|
user: User object with standard_hours_per_day setting
|
|
start_date: Start date of the period
|
|
end_date: End date of the period
|
|
|
|
Returns:
|
|
List of dictionaries with daily breakdown
|
|
"""
|
|
from app.models import TimeEntry
|
|
from app import db
|
|
|
|
# Get all time entries for the period
|
|
# Convert dates to datetime ranges to include full day
|
|
from datetime import datetime as dt
|
|
|
|
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()
|
|
)
|
|
|
|
# Group entries by date
|
|
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)
|
|
|
|
# Calculate overtime for each day
|
|
standard_hours = user.standard_hours_per_day
|
|
breakdown = []
|
|
|
|
for day_date in sorted(daily_data.keys()):
|
|
day_info = daily_data[day_date]
|
|
total_hours = day_info["total_hours"]
|
|
|
|
regular_hours = min(total_hours, standard_hours)
|
|
overtime_hours = max(0, total_hours - standard_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": round(regular_hours, 2),
|
|
"overtime_hours": round(overtime_hours, 2),
|
|
"is_overtime": overtime_hours > 0,
|
|
"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.
|
|
|
|
Args:
|
|
user: User object with standard_hours_per_day setting
|
|
weeks: Number of weeks to look back
|
|
|
|
Returns:
|
|
List of weekly summaries
|
|
"""
|
|
from app.models import TimeEntry
|
|
from app import db
|
|
|
|
end_date = datetime.now().date()
|
|
start_date = end_date - timedelta(weeks=weeks)
|
|
|
|
# Convert dates to datetime ranges to include full day
|
|
start_datetime = datetime.combine(start_date, datetime.min.time())
|
|
end_datetime = datetime.combine(end_date, datetime.max.time())
|
|
|
|
# Get all time entries
|
|
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
|
|
weekly_data = {}
|
|
for entry in entries:
|
|
entry_date = entry.start_time.date()
|
|
# Get Monday of that week
|
|
week_start = entry_date - timedelta(days=entry_date.weekday())
|
|
|
|
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
|
|
|
|
# Calculate overtime per week
|
|
standard_hours = user.standard_hours_per_day
|
|
weekly_summary = []
|
|
|
|
for week_start in sorted(weekly_data.keys()):
|
|
daily_hours = weekly_data[week_start]
|
|
|
|
week_regular = 0.0
|
|
week_overtime = 0.0
|
|
|
|
for day_date, hours in daily_hours.items():
|
|
if hours <= standard_hours:
|
|
week_regular += hours
|
|
else:
|
|
week_regular += standard_hours
|
|
week_overtime += hours - standard_hours
|
|
|
|
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_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,
|
|
},
|
|
}
|