Add time rounding option.

This commit is contained in:
2025-07-02 16:15:35 +02:00
committed by Jens Luedicke
parent 0db0531fea
commit 197ffde545
4 changed files with 349 additions and 14 deletions

61
app.py
View File

@@ -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/<int:entry_id>', 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

View File

@@ -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)

View File

@@ -46,7 +46,145 @@
</div>
</div>
<div class="form-section">
<h3>Time Rounding Settings</h3>
<p class="section-description">
Time rounding helps standardize billing and reporting by rounding time entries to specified intervals.
</p>
<div class="form-group">
<label for="time_rounding_minutes">Time Rounding Interval:</label>
<select id="time_rounding_minutes" name="time_rounding_minutes">
{% for value, label in rounding_options %}
<option value="{{ value }}" {% if config.time_rounding_minutes == value %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
<small>Round time entries to the nearest interval</small>
</div>
<div class="form-group">
<div class="checkbox-group">
<input type="checkbox"
id="round_to_nearest"
name="round_to_nearest"
{% if config.round_to_nearest %}checked{% endif %}>
<label for="round_to_nearest">Round to nearest interval</label>
<small>If unchecked, will always round up</small>
</div>
</div>
<div class="rounding-example" id="rounding-example">
<h4>Example:</h4>
<div id="example-text">
<!-- JavaScript will populate this -->
</div>
</div>
</div>
<button type="submit" class="btn btn-primary">Save Configuration</button>
</form>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const roundingSelect = document.getElementById('time_rounding_minutes');
const roundToNearestCheckbox = document.getElementById('round_to_nearest');
const exampleDiv = document.getElementById('example-text');
function updateExample() {
const interval = parseInt(roundingSelect.value);
const roundToNearest = roundToNearestCheckbox.checked;
if (interval === 0) {
exampleDiv.innerHTML = '<em>No rounding applied. Times are recorded exactly as entered.</em>';
return;
}
const roundingType = roundToNearest ? 'nearest' : 'up';
const examples = [];
if (interval === 15) {
if (roundToNearest) {
examples.push('9:07 AM → 9:00 AM');
examples.push('9:08 AM → 9:15 AM');
examples.push('9:23 AM → 9:30 AM');
} else {
examples.push('9:01 AM → 9:15 AM');
examples.push('9:16 AM → 9:30 AM');
examples.push('9:31 AM → 9:45 AM');
}
} else if (interval === 30) {
if (roundToNearest) {
examples.push('9:14 AM → 9:00 AM');
examples.push('9:16 AM → 9:30 AM');
examples.push('9:45 AM → 10:00 AM');
} else {
examples.push('9:01 AM → 9:30 AM');
examples.push('9:31 AM → 10:00 AM');
examples.push('10:01 AM → 10:30 AM');
}
} else if (interval === 60) {
if (roundToNearest) {
examples.push('9:29 AM → 9:00 AM');
examples.push('9:31 AM → 10:00 AM');
examples.push('10:30 AM → 11:00 AM');
} else {
examples.push('9:01 AM → 10:00 AM');
examples.push('10:01 AM → 11:00 AM');
examples.push('11:01 AM → 12:00 PM');
}
}
const exampleText = `Rounding ${roundingType} to ${interval} minute intervals:<br>` +
examples.map(ex => `${ex}`).join('<br>');
exampleDiv.innerHTML = exampleText;
}
// Update example when settings change
roundingSelect.addEventListener('change', updateExample);
roundToNearestCheckbox.addEventListener('change', updateExample);
// Initial example
updateExample();
});
</script>
<style>
.section-description {
color: #666;
font-style: italic;
margin-bottom: 1.5rem;
}
.checkbox-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.checkbox-group input[type="checkbox"] {
width: auto;
margin: 0;
}
.checkbox-group label {
margin: 0;
font-weight: normal;
}
.rounding-example {
background: #e8f5e9;
padding: 1rem;
border-radius: 6px;
margin-top: 1rem;
border-left: 4px solid #4CAF50;
}
.rounding-example h4 {
margin-top: 0;
color: #2e7d32;
}
</style>
{% endblock %}

159
time_utils.py Normal file
View File

@@ -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")
]