diff --git a/app.py b/app.py index 341dd9c..1dffca4 100644 --- a/app.py +++ b/app.py @@ -8,6 +8,7 @@ from data_export import ( export_to_csv, export_to_excel, export_team_hours_to_csv, export_team_hours_to_excel, export_analytics_csv, export_analytics_excel ) +from time_utils import apply_time_rounding, round_duration_to_interval, get_user_rounding_settings import logging from datetime import datetime, time, timedelta import os @@ -134,7 +135,9 @@ def run_migrations(): work_config_migrations = [ ('additional_break_minutes', "ALTER TABLE work_config ADD COLUMN additional_break_minutes INTEGER DEFAULT 15"), ('additional_break_threshold_hours', "ALTER TABLE work_config ADD COLUMN additional_break_threshold_hours FLOAT DEFAULT 9.0"), - ('user_id', "ALTER TABLE work_config ADD COLUMN user_id INTEGER") + ('user_id', "ALTER TABLE work_config ADD COLUMN user_id INTEGER"), + ('time_rounding_minutes', "ALTER TABLE work_config ADD COLUMN time_rounding_minutes INTEGER DEFAULT 0"), + ('round_to_nearest', "ALTER TABLE work_config ADD COLUMN round_to_nearest BOOLEAN DEFAULT 1") ] for column_name, sql_command in work_config_migrations: @@ -1368,19 +1371,30 @@ def leave(entry_id): # Set the departure time departure_time = datetime.now() - entry.departure_time = departure_time + + # Apply time rounding if enabled + rounded_arrival, rounded_departure = apply_time_rounding(entry.arrival_time, departure_time, g.user) + entry.arrival_time = rounded_arrival + entry.departure_time = rounded_departure # If currently paused, add the final break duration if entry.is_paused and entry.pause_start_time: - final_break_duration = int((departure_time - entry.pause_start_time).total_seconds()) + final_break_duration = int((rounded_departure - entry.pause_start_time).total_seconds()) entry.total_break_duration += final_break_duration entry.is_paused = False entry.pause_start_time = None + # Apply rounding to break duration if enabled + interval_minutes, round_to_nearest = get_user_rounding_settings(g.user) + if interval_minutes > 0: + entry.total_break_duration = round_duration_to_interval( + entry.total_break_duration, interval_minutes, round_to_nearest + ) + # Calculate work duration considering breaks entry.duration, effective_break = calculate_work_duration( - entry.arrival_time, - departure_time, + rounded_arrival, + rounded_departure, entry.total_break_duration ) @@ -1446,6 +1460,10 @@ def config(): config.break_threshold_hours = float(request.form.get('break_threshold_hours', 6.0)) config.additional_break_minutes = int(request.form.get('additional_break_minutes', 15)) config.additional_break_threshold_hours = float(request.form.get('additional_break_threshold_hours', 9.0)) + + # Update time rounding settings + config.time_rounding_minutes = int(request.form.get('time_rounding_minutes', 0)) + config.round_to_nearest = 'round_to_nearest' in request.form db.session.commit() flash('Configuration updated successfully!', 'success') @@ -1453,7 +1471,11 @@ def config(): except ValueError: flash('Please enter valid numbers for all fields', 'error') - return render_template('config.html', title='Configuration', config=config) + # Import time utils for display options + from time_utils import get_available_rounding_options + rounding_options = get_available_rounding_options() + + return render_template('config.html', title='Configuration', config=config, rounding_options=rounding_options) @app.route('/api/delete/', methods=['DELETE']) @login_required @@ -1615,18 +1637,21 @@ def manual_entry(): if departure_datetime <= arrival_datetime: return jsonify({'error': 'End time must be after start time'}), 400 + # Apply time rounding if enabled + rounded_arrival, rounded_departure = apply_time_rounding(arrival_datetime, departure_datetime, g.user) + # Validate project access if project is specified if project_id: project = Project.query.get(project_id) if not project or not project.is_user_allowed(g.user): return jsonify({'error': 'Invalid or unauthorized project'}), 403 - # Check for overlapping entries for this user + # Check for overlapping entries for this user (using rounded times) overlapping_entry = TimeEntry.query.filter( TimeEntry.user_id == g.user.id, TimeEntry.departure_time.isnot(None), - TimeEntry.arrival_time < departure_datetime, - TimeEntry.departure_time > arrival_datetime + TimeEntry.arrival_time < rounded_departure, + TimeEntry.departure_time > rounded_arrival ).first() if overlapping_entry: @@ -1634,10 +1659,17 @@ def manual_entry(): 'error': 'This time entry overlaps with an existing entry' }), 400 - # Calculate total duration in seconds - total_duration = int((departure_datetime - arrival_datetime).total_seconds()) + # Calculate total duration in seconds (using rounded times) + total_duration = int((rounded_departure - rounded_arrival).total_seconds()) break_duration_seconds = break_minutes * 60 + # Apply rounding to break duration if enabled + interval_minutes, round_to_nearest = get_user_rounding_settings(g.user) + if interval_minutes > 0: + break_duration_seconds = round_duration_to_interval( + break_duration_seconds, interval_minutes, round_to_nearest + ) + # Validate break duration doesn't exceed total duration if break_duration_seconds >= total_duration: return jsonify({'error': 'Break duration cannot exceed total work duration'}), 400 @@ -1645,11 +1677,11 @@ def manual_entry(): # Calculate work duration (total duration minus breaks) work_duration = total_duration - break_duration_seconds - # Create the manual time entry + # Create the manual time entry (using rounded times) new_entry = TimeEntry( user_id=g.user.id, - arrival_time=arrival_datetime, - departure_time=departure_datetime, + arrival_time=rounded_arrival, + departure_time=rounded_departure, duration=work_duration, total_break_duration=break_duration_seconds, project_id=int(project_id) if project_id else None, @@ -1736,6 +1768,7 @@ def admin_settings(): return render_template('admin_settings.html', title='System Settings', settings=settings) + # Company Management Routes @app.route('/admin/company') @admin_required diff --git a/models.py b/models.py index 27ce75d..88c5942 100644 --- a/models.py +++ b/models.py @@ -238,6 +238,11 @@ class WorkConfig(db.Model): break_threshold_hours = db.Column(db.Float, default=6.0) # Work hours that trigger mandatory break additional_break_minutes = db.Column(db.Integer, default=15) # Default 15 minutes for additional break additional_break_threshold_hours = db.Column(db.Float, default=9.0) # Work hours that trigger additional break + + # Time rounding settings + time_rounding_minutes = db.Column(db.Integer, default=0) # 0 = no rounding, 15 = 15 min, 30 = 30 min + round_to_nearest = db.Column(db.Boolean, default=True) # True = round to nearest, False = round up + created_at = db.Column(db.DateTime, default=datetime.now) updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True) diff --git a/templates/config.html b/templates/config.html index 6641732..200ba6a 100644 --- a/templates/config.html +++ b/templates/config.html @@ -46,7 +46,145 @@ +
+

Time Rounding Settings

+

+ Time rounding helps standardize billing and reporting by rounding time entries to specified intervals. +

+ +
+ + + Round time entries to the nearest interval +
+ +
+
+ + + If unchecked, will always round up +
+
+ +
+

Example:

+
+ +
+
+
+ + + + + {% endblock %} \ No newline at end of file diff --git a/time_utils.py b/time_utils.py new file mode 100644 index 0000000..04a90ed --- /dev/null +++ b/time_utils.py @@ -0,0 +1,159 @@ +""" +Time utility functions for TimeTrack application. +Includes time rounding functionality. +""" + +from datetime import datetime, timedelta +import math + + +def round_time_to_interval(dt, interval_minutes, round_to_nearest=True): + """ + Round a datetime to the specified interval. + + Args: + dt (datetime): The datetime to round + interval_minutes (int): The interval in minutes (15, 30, etc.) + round_to_nearest (bool): If True, round to nearest interval; if False, round up + + Returns: + datetime: The rounded datetime + """ + if interval_minutes == 0: + return dt # No rounding + + # Calculate the number of minutes from midnight + minutes_from_midnight = dt.hour * 60 + dt.minute + + if round_to_nearest: + # Round to nearest interval + rounded_minutes = round(minutes_from_midnight / interval_minutes) * interval_minutes + else: + # Round up to next interval + rounded_minutes = math.ceil(minutes_from_midnight / interval_minutes) * interval_minutes + + # Convert back to hours and minutes + rounded_hour = int(rounded_minutes // 60) + rounded_minute = int(rounded_minutes % 60) + + # Handle case where rounding goes to next day + if rounded_hour >= 24: + rounded_hour = 0 + dt = dt + timedelta(days=1) + + # Create new datetime with rounded time (keep seconds as 0) + return dt.replace(hour=rounded_hour, minute=rounded_minute, second=0, microsecond=0) + + +def round_duration_to_interval(duration_seconds, interval_minutes, round_to_nearest=True): + """ + Round a duration to the specified interval. + + Args: + duration_seconds (int): The duration in seconds + interval_minutes (int): The interval in minutes (15, 30, etc.) + round_to_nearest (bool): If True, round to nearest interval; if False, round up + + Returns: + int: The rounded duration in seconds + """ + if interval_minutes == 0: + return duration_seconds # No rounding + + # Convert to minutes + duration_minutes = duration_seconds / 60 + interval_seconds = interval_minutes * 60 + + if round_to_nearest: + # Round to nearest interval + rounded_intervals = round(duration_minutes / interval_minutes) + else: + # Round up to next interval + rounded_intervals = math.ceil(duration_minutes / interval_minutes) + + return int(rounded_intervals * interval_seconds) + + +def get_user_rounding_settings(user): + """ + Get the time rounding settings for a user. + + Args: + user: The user object + + Returns: + tuple: (interval_minutes, round_to_nearest) + """ + work_config = user.work_config + if work_config: + return work_config.time_rounding_minutes, work_config.round_to_nearest + else: + return 0, True # Default: no rounding, round to nearest + + +def apply_time_rounding(arrival_time, departure_time, user): + """ + Apply time rounding to arrival and departure times based on user settings. + + Args: + arrival_time (datetime): The original arrival time + departure_time (datetime): The original departure time + user: The user object + + Returns: + tuple: (rounded_arrival_time, rounded_departure_time) + """ + interval_minutes, round_to_nearest = get_user_rounding_settings(user) + + if interval_minutes == 0: + return arrival_time, departure_time # No rounding + + # Round arrival time (typically round up to start billing later) + rounded_arrival = round_time_to_interval(arrival_time, interval_minutes, round_to_nearest) + + # Round departure time (typically round up to bill more time) + rounded_departure = round_time_to_interval(departure_time, interval_minutes, not round_to_nearest) + + # Ensure departure is still after arrival + if rounded_departure <= rounded_arrival: + # Add one interval to departure time + rounded_departure = rounded_departure + timedelta(minutes=interval_minutes) + + return rounded_arrival, rounded_departure + + +def format_rounding_interval(interval_minutes): + """ + Format the rounding interval for display. + + Args: + interval_minutes (int): The interval in minutes + + Returns: + str: Formatted interval description + """ + if interval_minutes == 0: + return "No rounding" + elif interval_minutes == 15: + return "15 minutes" + elif interval_minutes == 30: + return "30 minutes" + elif interval_minutes == 60: + return "1 hour" + else: + return f"{interval_minutes} minutes" + + +def get_available_rounding_options(): + """ + Get the available time rounding options. + + Returns: + list: List of tuples (value, label) + """ + return [ + (0, "No rounding"), + (15, "15 minutes"), + (30, "30 minutes"), + (60, "1 hour") + ] \ No newline at end of file