Add time rounding option.
This commit is contained in:
61
app.py
61
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
|
||||
)
|
||||
|
||||
@@ -1447,13 +1461,21 @@ def config():
|
||||
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')
|
||||
return redirect(url_for('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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
159
time_utils.py
Normal 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")
|
||||
]
|
||||
Reference in New Issue
Block a user