Merge pull request #142 from DRYTRIX/feat-Time-Rounding-Preferences

feat: Add per-user time rounding preferences
This commit is contained in:
Dries Peeters
2025-10-24 09:38:15 +02:00
committed by GitHub
12 changed files with 1803 additions and 11 deletions

View File

@@ -135,15 +135,20 @@ class TimeEntry(db.Model):
duration = self.end_time - self.start_time
raw_seconds = int(duration.total_seconds())
# Apply rounding
rounding_minutes = Config.ROUNDING_MINUTES
if rounding_minutes > 1:
# Round to nearest interval
minutes = raw_seconds / 60
rounded_minutes = round(minutes / rounding_minutes) * rounding_minutes
self.duration_seconds = int(rounded_minutes * 60)
# Apply per-user rounding if user preferences are set
if self.user and hasattr(self.user, 'time_rounding_enabled'):
from app.utils.time_rounding import apply_user_rounding
self.duration_seconds = apply_user_rounding(raw_seconds, self.user)
else:
self.duration_seconds = raw_seconds
# Fallback to global rounding setting for backward compatibility
rounding_minutes = Config.ROUNDING_MINUTES
if rounding_minutes > 1:
# Round to nearest interval
minutes = raw_seconds / 60
rounded_minutes = round(minutes / rounding_minutes) * rounding_minutes
self.duration_seconds = int(rounded_minutes * 60)
else:
self.duration_seconds = raw_seconds
def stop_timer(self, end_time=None):
"""Stop an active timer"""

View File

@@ -37,6 +37,11 @@ class User(UserMixin, db.Model):
time_format = db.Column(db.String(10), default='24h', nullable=False) # '12h' or '24h'
week_start_day = db.Column(db.Integer, default=1, nullable=False) # 0=Sunday, 1=Monday, etc.
# Time rounding preferences
time_rounding_enabled = db.Column(db.Boolean, default=True, nullable=False) # Enable/disable time rounding
time_rounding_minutes = db.Column(db.Integer, default=1, nullable=False) # Rounding interval: 1, 5, 10, 15, 30, 60
time_rounding_method = db.Column(db.String(10), default='nearest', nullable=False) # 'nearest', 'up', or 'down'
# Relationships
time_entries = db.relationship('TimeEntry', backref='user', lazy='dynamic', cascade='all, delete-orphan')
project_costs = db.relationship('ProjectCost', backref='user', lazy='dynamic', cascade='all, delete-orphan')

View File

@@ -86,6 +86,17 @@ def settings():
if preferred_language:
current_user.preferred_language = preferred_language
# Time rounding preferences
current_user.time_rounding_enabled = 'time_rounding_enabled' in request.form
time_rounding_minutes = request.form.get('time_rounding_minutes', type=int)
if time_rounding_minutes and time_rounding_minutes in [1, 5, 10, 15, 30, 60]:
current_user.time_rounding_minutes = time_rounding_minutes
time_rounding_method = request.form.get('time_rounding_method')
if time_rounding_method in ['nearest', 'up', 'down']:
current_user.time_rounding_method = time_rounding_method
# Save changes
if safe_commit(db.session):
# Log activity
@@ -122,10 +133,17 @@ def settings():
'fi': 'Suomi'
})
# Get time rounding options
from app.utils.time_rounding import get_available_rounding_intervals, get_available_rounding_methods
rounding_intervals = get_available_rounding_intervals()
rounding_methods = get_available_rounding_methods()
return render_template('user/settings.html',
user=current_user,
timezones=timezones,
languages=languages)
languages=languages,
rounding_intervals=rounding_intervals,
rounding_methods=rounding_methods)
@user_bp.route('/api/preferences', methods=['PATCH'])

View File

@@ -290,7 +290,7 @@
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ current_user.email if current_user.is_authenticated else '' }}</div>
</li>
<li><a href="{{ url_for('auth.profile') }}" class="flex items-center gap-2 px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700"><i class="fas fa-user w-4"></i> {{ _('Profile') }}</a></li>
<li><a href="{{ url_for('auth.edit_profile') }}" class="flex items-center gap-2 px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700"><i class="fas fa-cog w-4"></i> {{ _('Settings') }}</a></li>
<li><a href="{{ url_for('user.settings') }}" class="flex items-center gap-2 px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700"><i class="fas fa-cog w-4"></i> {{ _('Settings') }}</a></li>
<li class="border-t border-border-light dark:border-border-dark"><a href="{{ url_for('auth.logout') }}" class="flex items-center gap-2 px-4 py-2 text-sm text-rose-600 dark:text-rose-400 hover:bg-gray-100 dark:hover:bg-gray-700"><i class="fas fa-sign-out-alt w-4"></i> {{ _('Logout') }}</a></li>
</ul>
</div>

View File

@@ -9,7 +9,7 @@
</div>
<form method="POST" class="space-y-8">
{{ csrf_token() if csrf_token }}
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<!-- Profile Information -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
@@ -138,6 +138,68 @@
</div>
</div>
<!-- Time Rounding Preferences -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">
<i class="fas fa-clock mr-2"></i>{{ _('Time Rounding Preferences') }}
</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
{{ _('Configure how your time entries are rounded. This affects how durations are calculated when you stop timers.') }}
</p>
<div class="space-y-4">
<div class="flex items-center">
<input type="checkbox" id="time_rounding_enabled" name="time_rounding_enabled"
{% if user.time_rounding_enabled %}checked{% endif %}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
onchange="toggleRoundingOptions()">
<label for="time_rounding_enabled" class="ml-3 block text-sm text-gray-700 dark:text-gray-300">
<span class="font-medium">{{ _('Enable Time Rounding') }}</span>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ _('Round time entries to configured intervals') }}</p>
</label>
</div>
<div id="rounding-options" class="ml-8 space-y-4 border-l-2 border-gray-200 dark:border-gray-700 pl-4">
<div>
<label for="time_rounding_minutes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ _('Rounding Interval') }}
</label>
<select id="time_rounding_minutes" name="time_rounding_minutes"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white">
{% for minutes, label in rounding_intervals %}
<option value="{{ minutes }}" {% if user.time_rounding_minutes == minutes %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ _('Time entries will be rounded to this interval') }}</p>
</div>
<div>
<label for="time_rounding_method" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ _('Rounding Method') }}
</label>
<select id="time_rounding_method" name="time_rounding_method"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
onchange="updateRoundingMethodDescription()">
{% for method, label, description in rounding_methods %}
<option value="{{ method }}" data-description="{{ description }}" {% if user.time_rounding_method == method %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<p id="rounding-method-description" class="text-xs text-gray-500 dark:text-gray-400 mt-1"></p>
</div>
<!-- Example visualization -->
<div class="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-md">
<p class="text-sm font-medium text-blue-900 dark:text-blue-100 mb-2">
<i class="fas fa-info-circle mr-1"></i>{{ _('Example') }}
</p>
<div id="rounding-example" class="text-xs text-blue-800 dark:text-blue-200 space-y-1">
<!-- Will be populated by JavaScript -->
</div>
</div>
</div>
</div>
</div>
<!-- Regional Settings -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">
@@ -236,6 +298,83 @@ document.getElementById('email_notifications').addEventListener('change', functi
emailField.classList.remove('border-yellow-500');
}
});
// Toggle rounding options visibility
function toggleRoundingOptions() {
const enabled = document.getElementById('time_rounding_enabled').checked;
const options = document.getElementById('rounding-options');
if (enabled) {
options.style.opacity = '1';
options.querySelectorAll('select').forEach(select => select.disabled = false);
} else {
options.style.opacity = '0.5';
options.querySelectorAll('select').forEach(select => select.disabled = true);
}
updateRoundingExample();
}
// Update rounding method description
function updateRoundingMethodDescription() {
const select = document.getElementById('time_rounding_method');
const description = select.options[select.selectedIndex].getAttribute('data-description');
document.getElementById('rounding-method-description').textContent = description;
updateRoundingExample();
}
// Update rounding example visualization
function updateRoundingExample() {
const enabled = document.getElementById('time_rounding_enabled').checked;
const minutes = parseInt(document.getElementById('time_rounding_minutes').value);
const method = document.getElementById('time_rounding_method').value;
const exampleDiv = document.getElementById('rounding-example');
if (!enabled) {
exampleDiv.innerHTML = '<p>{{ _("Time rounding is disabled. All times will be recorded exactly as tracked.") }}</p>';
return;
}
if (minutes === 1) {
exampleDiv.innerHTML = '<p>{{ _("No rounding - times will be recorded exactly as tracked.") }}</p>';
return;
}
// Calculate examples
const testDuration = 62; // 62 minutes = 1h 2min
let rounded;
if (method === 'up') {
rounded = Math.ceil(testDuration / minutes) * minutes;
} else if (method === 'down') {
rounded = Math.floor(testDuration / minutes) * minutes;
} else {
rounded = Math.round(testDuration / minutes) * minutes;
}
const formatTime = (mins) => {
const hours = Math.floor(mins / 60);
const remainingMins = mins % 60;
if (hours > 0) {
return remainingMins > 0 ? `${hours}h ${remainingMins}m` : `${hours}h`;
}
return `${remainingMins}m`;
};
exampleDiv.innerHTML = `
<p><strong>{{ _("Actual time:") }}</strong> ${formatTime(testDuration)} → <strong>{{ _("Rounded:") }}</strong> ${formatTime(rounded)}</p>
<p class="text-xs opacity-75">{{ _("With ") }}${minutes}{{ _(" minute intervals") }}</p>
`;
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
toggleRoundingOptions();
updateRoundingMethodDescription();
// Update example when settings change
document.getElementById('time_rounding_enabled').addEventListener('change', updateRoundingExample);
document.getElementById('time_rounding_minutes').addEventListener('change', updateRoundingExample);
});
</script>
{% endblock %}

153
app/utils/time_rounding.py Normal file
View File

@@ -0,0 +1,153 @@
"""Time rounding utilities for per-user time entry rounding preferences"""
import math
from typing import Optional
def round_time_duration(
duration_seconds: int,
rounding_minutes: int = 1,
rounding_method: str = 'nearest'
) -> int:
"""
Round a time duration in seconds based on the specified rounding settings.
Args:
duration_seconds: The raw duration in seconds
rounding_minutes: The rounding interval in minutes (e.g., 1, 5, 10, 15, 30, 60)
rounding_method: The rounding method ('nearest', 'up', or 'down')
Returns:
int: The rounded duration in seconds
Examples:
>>> round_time_duration(3720, 15, 'nearest') # 62 minutes -> 60 minutes (1 hour)
3600
>>> round_time_duration(3720, 15, 'up') # 62 minutes -> 75 minutes (1.25 hours)
4500
>>> round_time_duration(3720, 15, 'down') # 62 minutes -> 60 minutes (1 hour)
3600
"""
# If rounding is disabled (rounding_minutes = 1), return raw duration
if rounding_minutes <= 1:
return duration_seconds
# Validate rounding method
if rounding_method not in ('nearest', 'up', 'down'):
rounding_method = 'nearest'
# Convert to minutes for easier calculation
duration_minutes = duration_seconds / 60.0
# Apply rounding based on method
if rounding_method == 'up':
rounded_minutes = math.ceil(duration_minutes / rounding_minutes) * rounding_minutes
elif rounding_method == 'down':
rounded_minutes = math.floor(duration_minutes / rounding_minutes) * rounding_minutes
else: # 'nearest'
rounded_minutes = round(duration_minutes / rounding_minutes) * rounding_minutes
# Convert back to seconds
return int(rounded_minutes * 60)
def get_user_rounding_settings(user) -> dict:
"""
Get the time rounding settings for a user.
Args:
user: A User model instance
Returns:
dict: Dictionary with 'enabled', 'minutes', and 'method' keys
"""
return {
'enabled': getattr(user, 'time_rounding_enabled', True),
'minutes': getattr(user, 'time_rounding_minutes', 1),
'method': getattr(user, 'time_rounding_method', 'nearest')
}
def apply_user_rounding(duration_seconds: int, user) -> int:
"""
Apply a user's rounding preferences to a duration.
Args:
duration_seconds: The raw duration in seconds
user: A User model instance with rounding preferences
Returns:
int: The rounded duration in seconds
"""
settings = get_user_rounding_settings(user)
# If rounding is disabled for this user, return raw duration
if not settings['enabled']:
return duration_seconds
return round_time_duration(
duration_seconds,
settings['minutes'],
settings['method']
)
def format_rounding_interval(minutes: int) -> str:
"""
Format a rounding interval in minutes as a human-readable string.
Args:
minutes: The rounding interval in minutes
Returns:
str: A human-readable description
Examples:
>>> format_rounding_interval(1)
'No rounding (exact time)'
>>> format_rounding_interval(15)
'15 minutes'
>>> format_rounding_interval(60)
'1 hour'
"""
if minutes <= 1:
return 'No rounding (exact time)'
elif minutes == 60:
return '1 hour'
elif minutes >= 60:
hours = minutes // 60
return f'{hours} hour{"s" if hours > 1 else ""}'
else:
return f'{minutes} minute{"s" if minutes > 1 else ""}'
def get_available_rounding_intervals() -> list:
"""
Get the list of available rounding intervals.
Returns:
list: List of tuples (minutes, label)
"""
return [
(1, 'No rounding (exact time)'),
(5, '5 minutes'),
(10, '10 minutes'),
(15, '15 minutes'),
(30, '30 minutes'),
(60, '1 hour')
]
def get_available_rounding_methods() -> list:
"""
Get the list of available rounding methods.
Returns:
list: List of tuples (method, label, description)
"""
return [
('nearest', 'Round to nearest', 'Round to the nearest interval (standard rounding)'),
('up', 'Always round up', 'Always round up to the next interval (ceiling)'),
('down', 'Always round down', 'Always round down to the previous interval (floor)')
]

105
apply_migration.py Normal file
View File

@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""
Simple script to apply the time rounding preferences migration
"""
import os
import sys
# Add the project root to the path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from app import create_app, db
from sqlalchemy import inspect, text
def check_columns_exist():
"""Check if the time rounding columns already exist"""
app = create_app()
with app.app_context():
inspector = inspect(db.engine)
columns = [col['name'] for col in inspector.get_columns('users')]
has_enabled = 'time_rounding_enabled' in columns
has_minutes = 'time_rounding_minutes' in columns
has_method = 'time_rounding_method' in columns
return has_enabled, has_minutes, has_method
def apply_migration():
"""Apply the migration to add time rounding columns"""
app = create_app()
with app.app_context():
print("Applying time rounding preferences migration...")
# Check if columns already exist
has_enabled, has_minutes, has_method = check_columns_exist()
if has_enabled and has_minutes and has_method:
print("✓ Migration already applied! All columns exist.")
return True
# Apply the migration
try:
if not has_enabled:
print("Adding time_rounding_enabled column...")
db.session.execute(text(
"ALTER TABLE users ADD COLUMN time_rounding_enabled BOOLEAN DEFAULT 1 NOT NULL"
))
if not has_minutes:
print("Adding time_rounding_minutes column...")
db.session.execute(text(
"ALTER TABLE users ADD COLUMN time_rounding_minutes INTEGER DEFAULT 1 NOT NULL"
))
if not has_method:
print("Adding time_rounding_method column...")
db.session.execute(text(
"ALTER TABLE users ADD COLUMN time_rounding_method VARCHAR(10) DEFAULT 'nearest' NOT NULL"
))
db.session.commit()
print("✓ Migration applied successfully!")
# Verify
has_enabled, has_minutes, has_method = check_columns_exist()
if has_enabled and has_minutes and has_method:
print("✓ Verification passed! All columns exist.")
return True
else:
print("✗ Verification failed! Some columns are missing.")
return False
except Exception as e:
print(f"✗ Migration failed: {e}")
db.session.rollback()
return False
if __name__ == '__main__':
print("=== Time Rounding Preferences Migration ===")
print()
# Check current state
try:
has_enabled, has_minutes, has_method = check_columns_exist()
print("Current database state:")
print(f" - time_rounding_enabled: {'✓ exists' if has_enabled else '✗ missing'}")
print(f" - time_rounding_minutes: {'✓ exists' if has_minutes else '✗ missing'}")
print(f" - time_rounding_method: {'✓ exists' if has_method else '✗ missing'}")
print()
except Exception as e:
print(f"✗ Could not check database state: {e}")
sys.exit(1)
# Apply migration if needed
if has_enabled and has_minutes and has_method:
print("All columns already exist. No migration needed.")
else:
success = apply_migration()
if success:
print("\n✓ Migration complete! You can now use the time rounding preferences feature.")
print(" Please restart your application to load the changes.")
else:
print("\n✗ Migration failed. Please check the error messages above.")
sys.exit(1)

View File

@@ -0,0 +1,345 @@
# Time Rounding Preferences - Per-User Settings
## Overview
The Time Rounding Preferences feature allows each user to configure how their time entries are rounded when they stop timers. This provides flexibility for different billing practices and time tracking requirements while maintaining accurate time records.
## Key Features
- **Per-User Configuration**: Each user can set their own rounding preferences independently
- **Multiple Rounding Intervals**: Support for 1, 5, 10, 15, 30, and 60-minute intervals
- **Three Rounding Methods**:
- **Nearest**: Round to the closest interval (standard rounding)
- **Up**: Always round up to the next interval (ceiling)
- **Down**: Always round down to the previous interval (floor)
- **Enable/Disable Toggle**: Users can disable rounding to track exact time
- **Real-time Preview**: Visual examples show how rounding will be applied
## User Guide
### Accessing Rounding Settings
1. Navigate to **Settings** from the user menu
2. Scroll to the **Time Rounding Preferences** section
3. Configure your preferences:
- Toggle **Enable Time Rounding** on/off
- Select your preferred **Rounding Interval**
- Choose your **Rounding Method**
4. Click **Save Settings** to apply changes
### Understanding Rounding Methods
#### Round to Nearest (Default)
Standard mathematical rounding to the closest interval.
**Example** with 15-minute intervals:
- 7 minutes → 0 minutes
- 8 minutes → 15 minutes
- 62 minutes → 60 minutes
- 68 minutes → 75 minutes
#### Always Round Up
Always rounds up to the next interval, ensuring you never under-bill.
**Example** with 15-minute intervals:
- 1 minute → 15 minutes
- 61 minutes → 75 minutes
- 60 minutes → 60 minutes (exact match)
#### Always Round Down
Always rounds down to the previous interval, ensuring conservative billing.
**Example** with 15-minute intervals:
- 14 minutes → 0 minutes
- 74 minutes → 60 minutes
- 75 minutes → 75 minutes (exact match)
### Choosing the Right Settings
**For Freelancers/Contractors:**
- Use **15-minute intervals** with **Round to Nearest** for balanced billing
- Use **Round Up** if client agreements favor rounding up
- Use **5 or 10 minutes** for more granular tracking
**For Internal Time Tracking:**
- Use **No rounding (1 minute)** for exact time tracking
- Use **15 or 30 minutes** for simplified reporting
**For Project-Based Billing:**
- Use **30 or 60 minutes** for project-level granularity
- Use **Round Down** for conservative estimates
## Technical Details
### Database Schema
The following fields are added to the `users` table:
```sql
time_rounding_enabled BOOLEAN DEFAULT 1 NOT NULL
time_rounding_minutes INTEGER DEFAULT 1 NOT NULL
time_rounding_method VARCHAR(10) DEFAULT 'nearest' NOT NULL
```
### Default Values
For new and existing users:
- **Enabled**: `True` (rounding is enabled by default)
- **Minutes**: `1` (no rounding, exact time)
- **Method**: `'nearest'` (standard rounding)
### How Rounding is Applied
1. **Timer Start**: When a user starts a timer, no rounding is applied
2. **Timer Stop**: When a user stops a timer:
- Calculate raw duration (end time - start time)
- Apply user's rounding preferences
- Store rounded duration in `duration_seconds` field
3. **Manual Entries**: Rounding is applied when creating/editing manual entries
### Backward Compatibility
The feature is fully backward compatible:
- If user preferences don't exist, the system falls back to the global `ROUNDING_MINUTES` config setting
- Existing time entries are not retroactively rounded
- Users without the new fields will use global rounding settings
## API Integration
### Get User Rounding Settings
```python
from app.utils.time_rounding import get_user_rounding_settings
settings = get_user_rounding_settings(user)
# Returns: {'enabled': True, 'minutes': 15, 'method': 'nearest'}
```
### Apply Rounding to Duration
```python
from app.utils.time_rounding import apply_user_rounding
raw_seconds = 3720 # 62 minutes
rounded_seconds = apply_user_rounding(raw_seconds, user)
# Returns: 3600 (60 minutes) with 15-min nearest rounding
```
### Manual Rounding
```python
from app.utils.time_rounding import round_time_duration
rounded = round_time_duration(
duration_seconds=3720, # 62 minutes
rounding_minutes=15,
rounding_method='up'
)
# Returns: 4500 (75 minutes)
```
## Migration Guide
### Applying the Migration
Run the Alembic migration to add the new fields:
```bash
# Using Alembic
alembic upgrade head
# Or using the migration script
python migrations/manage_migrations.py upgrade
```
### Migration Details
- **Migration File**: `migrations/versions/027_add_user_time_rounding_preferences.py`
- **Adds**: Three new columns to the `users` table
- **Safe**: Non-destructive, adds columns with default values
- **Rollback**: Supported via downgrade function
### Verifying Migration
```python
from app.models import User
from app import db
# Check if fields exist
user = User.query.first()
assert hasattr(user, 'time_rounding_enabled')
assert hasattr(user, 'time_rounding_minutes')
assert hasattr(user, 'time_rounding_method')
# Check default values
assert user.time_rounding_enabled == True
assert user.time_rounding_minutes == 1
assert user.time_rounding_method == 'nearest'
```
## Configuration
### Available Rounding Intervals
The following intervals are supported:
- `1` - No rounding (exact time)
- `5` - 5 minutes
- `10` - 10 minutes
- `15` - 15 minutes
- `30` - 30 minutes (half hour)
- `60` - 60 minutes (1 hour)
### Available Rounding Methods
Three methods are supported:
- `'nearest'` - Round to nearest interval
- `'up'` - Always round up (ceiling)
- `'down'` - Always round down (floor)
### Global Fallback Setting
If per-user rounding is not configured, the system uses the global setting:
```python
# In app/config.py
ROUNDING_MINUTES = int(os.environ.get('ROUNDING_MINUTES', 1))
```
## Testing
### Running Tests
```bash
# Run all time rounding tests
pytest tests/test_time_rounding*.py -v
# Run specific test suites
pytest tests/test_time_rounding.py -v # Unit tests
pytest tests/test_time_rounding_models.py -v # Model integration tests
pytest tests/test_time_rounding_smoke.py -v # Smoke tests
```
### Test Coverage
The feature includes:
- **Unit Tests**: Core rounding logic (50+ test cases)
- **Model Tests**: Database integration and TimeEntry model
- **Smoke Tests**: End-to-end workflows and edge cases
## Examples
### Example 1: Freelancer with 15-Minute Billing
```python
# User settings
user.time_rounding_enabled = True
user.time_rounding_minutes = 15
user.time_rounding_method = 'nearest'
# Time entry: 62 minutes
# Result: 60 minutes (rounded to nearest 15-min interval)
```
### Example 2: Contractor with Round-Up Policy
```python
# User settings
user.time_rounding_enabled = True
user.time_rounding_minutes = 15
user.time_rounding_method = 'up'
# Time entry: 61 minutes
# Result: 75 minutes (rounded up to next 15-min interval)
```
### Example 3: Exact Time Tracking
```python
# User settings
user.time_rounding_enabled = False
# Time entry: 62 minutes 37 seconds
# Result: 62 minutes 37 seconds (3757 seconds, exact)
```
### Example 4: Conservative Billing
```python
# User settings
user.time_rounding_enabled = True
user.time_rounding_minutes = 30
user.time_rounding_method = 'down'
# Time entry: 62 minutes
# Result: 60 minutes (rounded down to previous 30-min interval)
```
## Troubleshooting
### Rounding Not Applied
**Issue**: Time entries are not being rounded despite settings being enabled.
**Solutions**:
1. Verify rounding is enabled: Check `user.time_rounding_enabled == True`
2. Check rounding interval: Ensure `user.time_rounding_minutes > 1`
3. Verify migration was applied: Check if columns exist in database
4. Clear cache and restart application
### Unexpected Rounding Results
**Issue**: Durations are rounded differently than expected.
**Solutions**:
1. Verify rounding method setting (nearest/up/down)
2. Check the actual rounding interval (minutes value)
3. Test with example calculations using the utility functions
4. Review the rounding method documentation
### Migration Fails
**Issue**: Alembic migration fails to apply.
**Solutions**:
1. Check database permissions
2. Verify no conflicting migrations
3. Run `alembic current` to check migration state
4. Try manual column addition as fallback
5. Check logs for specific error messages
## Best Practices
1. **Choose Appropriate Intervals**: Match your rounding to billing agreements
2. **Document Your Choice**: Note why you chose specific rounding settings
3. **Test Before Production**: Verify rounding behavior with test entries
4. **Communicate with Clients**: Ensure clients understand your rounding policy
5. **Review Regularly**: Periodically review if rounding settings still make sense
6. **Keep Records**: Document any changes to rounding preferences
## Future Enhancements
Potential improvements for future versions:
- Project-specific rounding overrides
- Time-of-day based rounding rules
- Client-specific rounding preferences
- Rounding reports and analytics
- Bulk update of historical entries with new rounding
## Support
For issues or questions:
1. Check this documentation first
2. Review test files for usage examples
3. Check the codebase in `app/utils/time_rounding.py`
4. Open an issue on the project repository
## Changelog
### Version 1.0 (2025-10-24)
- Initial implementation of per-user time rounding preferences
- Support for 6 rounding intervals (1, 5, 10, 15, 30, 60 minutes)
- Support for 3 rounding methods (nearest, up, down)
- UI integration in user settings page
- Comprehensive test coverage
- Full backward compatibility with global rounding settings

View File

@@ -0,0 +1,70 @@
"""Add user time rounding preferences
Revision ID: 027
Revises: 026
Create Date: 2025-10-24 00:00:00
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '027'
down_revision = '026'
branch_labels = None
depends_on = None
def upgrade():
"""Add time rounding preference fields to users table"""
bind = op.get_bind()
dialect_name = bind.dialect.name if bind else 'generic'
# Add time rounding preferences to users table
try:
# Enable/disable time rounding for this user
op.add_column('users', sa.Column('time_rounding_enabled', sa.Boolean(), nullable=False, server_default='1'))
print("✓ Added time_rounding_enabled column to users table")
except Exception as e:
print(f"⚠ Warning adding time_rounding_enabled column: {e}")
try:
# Rounding interval in minutes (1, 5, 10, 15, 30, 60)
# Default to 1 (no rounding, use exact time)
op.add_column('users', sa.Column('time_rounding_minutes', sa.Integer(), nullable=False, server_default='1'))
print("✓ Added time_rounding_minutes column to users table")
except Exception as e:
print(f"⚠ Warning adding time_rounding_minutes column: {e}")
try:
# Rounding method: 'nearest', 'up', 'down'
# 'nearest' = round to nearest interval
# 'up' = always round up (ceil)
# 'down' = always round down (floor)
op.add_column('users', sa.Column('time_rounding_method', sa.String(10), nullable=False, server_default='nearest'))
print("✓ Added time_rounding_method column to users table")
except Exception as e:
print(f"⚠ Warning adding time_rounding_method column: {e}")
def downgrade():
"""Remove time rounding preference fields from users table"""
try:
op.drop_column('users', 'time_rounding_method')
print("✓ Dropped time_rounding_method column from users table")
except Exception as e:
print(f"⚠ Warning dropping time_rounding_method column: {e}")
try:
op.drop_column('users', 'time_rounding_minutes')
print("✓ Dropped time_rounding_minutes column from users table")
except Exception as e:
print(f"⚠ Warning dropping time_rounding_minutes column: {e}")
try:
op.drop_column('users', 'time_rounding_enabled')
print("✓ Dropped time_rounding_enabled column from users table")
except Exception as e:
print(f"⚠ Warning dropping time_rounding_enabled column: {e}")

197
tests/test_time_rounding.py Normal file
View File

@@ -0,0 +1,197 @@
"""Unit tests for time rounding functionality"""
import pytest
from app.utils.time_rounding import (
round_time_duration,
apply_user_rounding,
format_rounding_interval,
get_available_rounding_intervals,
get_available_rounding_methods,
get_user_rounding_settings
)
class TestRoundTimeDuration:
"""Test the core time rounding function"""
def test_no_rounding_when_interval_is_one(self):
"""Test that rounding_minutes=1 returns exact duration"""
assert round_time_duration(3720, 1, 'nearest') == 3720
assert round_time_duration(3722, 1, 'up') == 3722
assert round_time_duration(3718, 1, 'down') == 3718
def test_round_to_nearest_5_minutes(self):
"""Test rounding to nearest 5 minute interval"""
# 62 minutes should round to 60 minutes (nearest 5-min interval)
assert round_time_duration(3720, 5, 'nearest') == 3600
# 63 minutes should round to 65 minutes
assert round_time_duration(3780, 5, 'nearest') == 3900
# 2 minutes should round to 0
assert round_time_duration(120, 5, 'nearest') == 0
# 3 minutes should round to 5
assert round_time_duration(180, 5, 'nearest') == 300
def test_round_to_nearest_15_minutes(self):
"""Test rounding to nearest 15 minute interval"""
# 62 minutes should round to 60 minutes
assert round_time_duration(3720, 15, 'nearest') == 3600
# 68 minutes should round to 75 minutes
assert round_time_duration(4080, 15, 'nearest') == 4500
# 7 minutes should round to 0
assert round_time_duration(420, 15, 'nearest') == 0
# 8 minutes should round to 15
assert round_time_duration(480, 15, 'nearest') == 900
def test_round_up(self):
"""Test always rounding up (ceiling)"""
# 62 minutes with 15-min interval rounds up to 75
assert round_time_duration(3720, 15, 'up') == 4500
# 60 minutes with 15-min interval stays 60 (exact match)
assert round_time_duration(3600, 15, 'up') == 3600
# 61 minutes with 15-min interval rounds up to 75
assert round_time_duration(3660, 15, 'up') == 4500
# 1 minute with 5-min interval rounds up to 5
assert round_time_duration(60, 5, 'up') == 300
def test_round_down(self):
"""Test always rounding down (floor)"""
# 62 minutes with 15-min interval rounds down to 60
assert round_time_duration(3720, 15, 'down') == 3600
# 74 minutes with 15-min interval rounds down to 60
assert round_time_duration(4440, 15, 'down') == 3600
# 75 minutes with 15-min interval stays 75 (exact match)
assert round_time_duration(4500, 15, 'down') == 4500
def test_round_to_hour(self):
"""Test rounding to 1 hour intervals"""
# 62 minutes rounds to 60 minutes (nearest hour)
assert round_time_duration(3720, 60, 'nearest') == 3600
# 90 minutes rounds to 120 minutes (nearest hour)
assert round_time_duration(5400, 60, 'nearest') == 7200
# 89 minutes rounds to 60 minutes (nearest hour)
assert round_time_duration(5340, 60, 'nearest') == 3600
def test_invalid_rounding_method_defaults_to_nearest(self):
"""Test that invalid rounding method falls back to 'nearest'"""
result = round_time_duration(3720, 15, 'invalid')
expected = round_time_duration(3720, 15, 'nearest')
assert result == expected
def test_zero_duration(self):
"""Test handling of zero duration"""
assert round_time_duration(0, 15, 'nearest') == 0
assert round_time_duration(0, 15, 'up') == 0
assert round_time_duration(0, 15, 'down') == 0
def test_very_small_durations(self):
"""Test rounding of very small durations"""
# 30 seconds with 5-min rounding
assert round_time_duration(30, 5, 'nearest') == 0
assert round_time_duration(30, 5, 'up') == 300 # Rounds up to 5 minutes
assert round_time_duration(30, 5, 'down') == 0
def test_very_large_durations(self):
"""Test rounding of large durations"""
# 8 hours 7 minutes (487 minutes) with 15-min rounding
assert round_time_duration(29220, 15, 'nearest') == 29100 # 485 minutes
# 8 hours 8 minutes (488 minutes) with 15-min rounding
assert round_time_duration(29280, 15, 'nearest') == 29100 # 485 minutes
class TestApplyUserRounding:
"""Test applying user-specific rounding preferences"""
def test_with_rounding_disabled(self):
"""Test that rounding is skipped when disabled for user"""
class MockUser:
time_rounding_enabled = False
time_rounding_minutes = 15
time_rounding_method = 'nearest'
user = MockUser()
assert apply_user_rounding(3720, user) == 3720
def test_with_rounding_enabled(self):
"""Test that rounding is applied when enabled"""
class MockUser:
time_rounding_enabled = True
time_rounding_minutes = 15
time_rounding_method = 'nearest'
user = MockUser()
# 62 minutes should round to 60 with 15-min interval
assert apply_user_rounding(3720, user) == 3600
def test_different_user_preferences(self):
"""Test that different users can have different rounding settings"""
class MockUser1:
time_rounding_enabled = True
time_rounding_minutes = 5
time_rounding_method = 'up'
class MockUser2:
time_rounding_enabled = True
time_rounding_minutes = 15
time_rounding_method = 'down'
duration = 3720 # 62 minutes
# User 1: 5-min up -> 65 minutes
assert apply_user_rounding(duration, MockUser1()) == 3900
# User 2: 15-min down -> 60 minutes
assert apply_user_rounding(duration, MockUser2()) == 3600
def test_get_user_rounding_settings(self):
"""Test retrieving user rounding settings"""
class MockUser:
time_rounding_enabled = True
time_rounding_minutes = 10
time_rounding_method = 'up'
settings = get_user_rounding_settings(MockUser())
assert settings['enabled'] is True
assert settings['minutes'] == 10
assert settings['method'] == 'up'
def test_get_user_rounding_settings_with_defaults(self):
"""Test default values when attributes don't exist"""
class MockUser:
pass
settings = get_user_rounding_settings(MockUser())
assert settings['enabled'] is True
assert settings['minutes'] == 1
assert settings['method'] == 'nearest'
class TestFormattingFunctions:
"""Test formatting and helper functions"""
def test_format_rounding_interval(self):
"""Test formatting of rounding intervals"""
assert format_rounding_interval(1) == 'No rounding (exact time)'
assert format_rounding_interval(5) == '5 minutes'
assert format_rounding_interval(15) == '15 minutes'
assert format_rounding_interval(30) == '30 minutes'
assert format_rounding_interval(60) == '1 hour'
assert format_rounding_interval(120) == '2 hours'
def test_get_available_rounding_intervals(self):
"""Test getting available rounding intervals"""
intervals = get_available_rounding_intervals()
assert len(intervals) == 6
assert (1, 'No rounding (exact time)') in intervals
assert (5, '5 minutes') in intervals
assert (60, '1 hour') in intervals
def test_get_available_rounding_methods(self):
"""Test getting available rounding methods"""
methods = get_available_rounding_methods()
assert len(methods) == 3
method_values = [m[0] for m in methods]
assert 'nearest' in method_values
assert 'up' in method_values
assert 'down' in method_values

View File

@@ -0,0 +1,350 @@
"""Model tests for time rounding preferences integration"""
import pytest
from datetime import datetime, timedelta
from app import create_app, db
from app.models import User, Project, TimeEntry
from app.utils.time_rounding import apply_user_rounding
@pytest.fixture
def app():
"""Create application for testing"""
app = create_app()
app.config['TESTING'] = True
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
app.config['WTF_CSRF_ENABLED'] = False
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture
def client(app):
"""Create test client"""
return app.test_client()
@pytest.fixture
def test_user(app):
"""Create a test user with default rounding preferences"""
with app.app_context():
user = User(username='testuser', role='user')
user.time_rounding_enabled = True
user.time_rounding_minutes = 15
user.time_rounding_method = 'nearest'
db.session.add(user)
db.session.commit()
# Return the user ID instead of the object
user_id = user.id
db.session.expunge_all()
# Re-query the user in a new session
with app.app_context():
return User.query.get(user_id)
@pytest.fixture
def test_project(app, test_user):
"""Create a test project"""
with app.app_context():
user = User.query.get(test_user.id)
project = Project(
name='Test Project',
client='Test Client',
status='active',
created_by_id=user.id
)
db.session.add(project)
db.session.commit()
project_id = project.id
db.session.expunge_all()
with app.app_context():
return Project.query.get(project_id)
class TestUserRoundingPreferences:
"""Test User model rounding preference fields"""
def test_user_has_rounding_fields(self, app, test_user):
"""Test that user model has rounding preference fields"""
with app.app_context():
user = User.query.get(test_user.id)
assert hasattr(user, 'time_rounding_enabled')
assert hasattr(user, 'time_rounding_minutes')
assert hasattr(user, 'time_rounding_method')
def test_user_default_rounding_values(self, app):
"""Test default rounding values for new users"""
with app.app_context():
user = User(username='newuser', role='user')
db.session.add(user)
db.session.commit()
# Defaults should be: enabled=True, minutes=1, method='nearest'
assert user.time_rounding_enabled is True
assert user.time_rounding_minutes == 1
assert user.time_rounding_method == 'nearest'
def test_update_user_rounding_preferences(self, app, test_user):
"""Test updating user rounding preferences"""
with app.app_context():
user = User.query.get(test_user.id)
# Update preferences
user.time_rounding_enabled = False
user.time_rounding_minutes = 30
user.time_rounding_method = 'up'
db.session.commit()
# Verify changes persisted
user_id = user.id
db.session.expunge_all()
user = User.query.get(user_id)
assert user.time_rounding_enabled is False
assert user.time_rounding_minutes == 30
assert user.time_rounding_method == 'up'
def test_multiple_users_different_preferences(self, app):
"""Test that different users can have different rounding preferences"""
with app.app_context():
user1 = User(username='user1', role='user')
user1.time_rounding_enabled = True
user1.time_rounding_minutes = 5
user1.time_rounding_method = 'up'
user2 = User(username='user2', role='user')
user2.time_rounding_enabled = False
user2.time_rounding_minutes = 15
user2.time_rounding_method = 'down'
db.session.add_all([user1, user2])
db.session.commit()
# Verify each user has their own settings
assert user1.time_rounding_minutes == 5
assert user2.time_rounding_minutes == 15
assert user1.time_rounding_method == 'up'
assert user2.time_rounding_method == 'down'
class TestTimeEntryRounding:
"""Test time entry duration calculation with per-user rounding"""
def test_time_entry_uses_user_rounding(self, app, test_user, test_project):
"""Test that time entry uses user's rounding preferences"""
with app.app_context():
user = User.query.get(test_user.id)
project = Project.query.get(test_project.id)
# Create a time entry with 62 minutes duration
start_time = datetime(2025, 1, 1, 10, 0, 0)
end_time = start_time + timedelta(minutes=62)
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=start_time,
end_time=end_time
)
db.session.add(entry)
db.session.flush()
# User has 15-min nearest rounding, so 62 minutes should round to 60
assert entry.duration_seconds == 3600 # 60 minutes
def test_time_entry_respects_disabled_rounding(self, app, test_user, test_project):
"""Test that rounding is not applied when disabled"""
with app.app_context():
user = User.query.get(test_user.id)
project = Project.query.get(test_project.id)
# Disable rounding for user
user.time_rounding_enabled = False
db.session.commit()
# Create a time entry with 62 minutes duration
start_time = datetime(2025, 1, 1, 10, 0, 0)
end_time = start_time + timedelta(minutes=62, seconds=30)
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=start_time,
end_time=end_time
)
db.session.add(entry)
db.session.flush()
# With rounding disabled, should be exact: 62.5 minutes = 3750 seconds
assert entry.duration_seconds == 3750
def test_time_entry_round_up_method(self, app, test_user, test_project):
"""Test time entry with 'up' rounding method"""
with app.app_context():
user = User.query.get(test_user.id)
project = Project.query.get(test_project.id)
# Set to round up with 15-minute intervals
user.time_rounding_method = 'up'
db.session.commit()
# Create entry with 61 minutes (should round up to 75)
start_time = datetime(2025, 1, 1, 10, 0, 0)
end_time = start_time + timedelta(minutes=61)
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=start_time,
end_time=end_time
)
db.session.add(entry)
db.session.flush()
# 61 minutes rounds up to 75 minutes (next 15-min interval)
assert entry.duration_seconds == 4500 # 75 minutes
def test_time_entry_round_down_method(self, app, test_user, test_project):
"""Test time entry with 'down' rounding method"""
with app.app_context():
user = User.query.get(test_user.id)
project = Project.query.get(test_project.id)
# Set to round down with 15-minute intervals
user.time_rounding_method = 'down'
db.session.commit()
# Create entry with 74 minutes (should round down to 60)
start_time = datetime(2025, 1, 1, 10, 0, 0)
end_time = start_time + timedelta(minutes=74)
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=start_time,
end_time=end_time
)
db.session.add(entry)
db.session.flush()
# 74 minutes rounds down to 60 minutes
assert entry.duration_seconds == 3600 # 60 minutes
def test_time_entry_different_intervals(self, app, test_user, test_project):
"""Test time entries with different rounding intervals"""
with app.app_context():
user = User.query.get(test_user.id)
project = Project.query.get(test_project.id)
start_time = datetime(2025, 1, 1, 10, 0, 0)
end_time = start_time + timedelta(minutes=62)
# Test 5-minute rounding
user.time_rounding_minutes = 5
db.session.commit()
entry1 = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=start_time,
end_time=end_time
)
db.session.add(entry1)
db.session.flush()
# 62 minutes rounds to 60 with 5-min intervals
assert entry1.duration_seconds == 3600
# Test 30-minute rounding
user.time_rounding_minutes = 30
db.session.commit()
entry2 = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=start_time,
end_time=end_time
)
db.session.add(entry2)
db.session.flush()
# 62 minutes rounds to 60 with 30-min intervals
assert entry2.duration_seconds == 3600
def test_stop_timer_applies_rounding(self, app, test_user, test_project):
"""Test that stopping a timer applies user's rounding preferences"""
with app.app_context():
user = User.query.get(test_user.id)
project = Project.query.get(test_project.id)
# Create an active timer
start_time = datetime(2025, 1, 1, 10, 0, 0)
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=start_time,
end_time=None
)
db.session.add(entry)
db.session.commit()
# Stop the timer after 62 minutes
end_time = start_time + timedelta(minutes=62)
entry.stop_timer(end_time=end_time)
# Should be rounded to 60 minutes (user has 15-min nearest rounding)
assert entry.duration_seconds == 3600
class TestBackwardCompatibility:
"""Test backward compatibility with global rounding settings"""
def test_fallback_to_global_rounding_without_user_preferences(self, app, test_project):
"""Test that system falls back to global rounding if user prefs don't exist"""
with app.app_context():
# Create a user without rounding preferences (simulating old database)
user = User(username='olduser', role='user')
db.session.add(user)
db.session.flush()
# Remove the new attributes to simulate old schema
if hasattr(user, 'time_rounding_enabled'):
delattr(user, 'time_rounding_enabled')
if hasattr(user, 'time_rounding_minutes'):
delattr(user, 'time_rounding_minutes')
if hasattr(user, 'time_rounding_method'):
delattr(user, 'time_rounding_method')
project = Project.query.get(test_project.id)
# Create a time entry - should fall back to global rounding
start_time = datetime(2025, 1, 1, 10, 0, 0)
end_time = start_time + timedelta(minutes=62)
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=start_time,
end_time=end_time
)
db.session.add(entry)
db.session.flush()
# Should use global rounding (Config.ROUNDING_MINUTES, default is 1)
# With global rounding = 1, duration should be exact
assert entry.duration_seconds == 3720 # 62 minutes exactly

View File

@@ -0,0 +1,405 @@
"""Smoke tests for time rounding preferences feature - end-to-end testing"""
import pytest
from datetime import datetime, timedelta
from app import create_app, db
from app.models import User, Project, TimeEntry
@pytest.fixture
def app():
"""Create application for testing"""
app = create_app()
app.config['TESTING'] = True
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
app.config['WTF_CSRF_ENABLED'] = False
app.config['SERVER_NAME'] = 'localhost'
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture
def client(app):
"""Create test client"""
return app.test_client()
@pytest.fixture
def authenticated_user(app, client):
"""Create and authenticate a test user"""
with app.app_context():
user = User(username='smoketest', role='user', email='smoke@test.com')
user.time_rounding_enabled = True
user.time_rounding_minutes = 15
user.time_rounding_method = 'nearest'
db.session.add(user)
project = Project(
name='Smoke Test Project',
client='Smoke Test Client',
status='active',
created_by_id=1
)
db.session.add(project)
db.session.commit()
user_id = user.id
project_id = project.id
# Simulate login
with client.session_transaction() as sess:
sess['user_id'] = user_id
sess['_fresh'] = True
return {'user_id': user_id, 'project_id': project_id}
class TestTimeRoundingFeatureSmokeTests:
"""High-level smoke tests for the time rounding feature"""
def test_user_can_view_rounding_settings(self, app, client, authenticated_user):
"""Test that user can access the settings page with rounding options"""
with app.test_request_context():
response = client.get('/settings')
# Should be able to access settings page
assert response.status_code in [200, 302] # 302 if redirect to login
def test_user_can_update_rounding_preferences(self, app, client, authenticated_user):
"""Test that user can update their rounding preferences"""
with app.app_context():
user = User.query.get(authenticated_user['user_id'])
# Change preferences
user.time_rounding_enabled = False
user.time_rounding_minutes = 30
user.time_rounding_method = 'up'
db.session.commit()
# Verify changes were saved
db.session.expunge_all()
user = User.query.get(authenticated_user['user_id'])
assert user.time_rounding_enabled is False
assert user.time_rounding_minutes == 30
assert user.time_rounding_method == 'up'
def test_time_entry_reflects_user_rounding_preferences(self, app, authenticated_user):
"""Test that creating a time entry applies user's rounding preferences"""
with app.app_context():
user = User.query.get(authenticated_user['user_id'])
project = Project.query.get(authenticated_user['project_id'])
# Create a time entry with 62 minutes
start_time = datetime(2025, 1, 1, 10, 0, 0)
end_time = start_time + timedelta(minutes=62)
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=start_time,
end_time=end_time
)
db.session.add(entry)
db.session.commit()
# User has 15-min nearest rounding, so 62 -> 60 minutes
assert entry.duration_seconds == 3600
assert entry.duration_hours == 1.0
def test_different_users_have_independent_rounding(self, app):
"""Test that different users can have different rounding settings"""
with app.app_context():
# Create two users with different preferences
user1 = User(username='user1', role='user')
user1.time_rounding_enabled = True
user1.time_rounding_minutes = 5
user1.time_rounding_method = 'nearest'
user2 = User(username='user2', role='user')
user2.time_rounding_enabled = True
user2.time_rounding_minutes = 30
user2.time_rounding_method = 'up'
db.session.add_all([user1, user2])
db.session.commit()
# Create a project
project = Project(
name='Test Project',
client='Test Client',
status='active',
created_by_id=user1.id
)
db.session.add(project)
db.session.commit()
# Create identical time entries for both users
start_time = datetime(2025, 1, 1, 10, 0, 0)
end_time = start_time + timedelta(minutes=62)
entry1 = TimeEntry(
user_id=user1.id,
project_id=project.id,
start_time=start_time,
end_time=end_time
)
entry2 = TimeEntry(
user_id=user2.id,
project_id=project.id,
start_time=start_time,
end_time=end_time
)
db.session.add_all([entry1, entry2])
db.session.commit()
# User1 (5-min nearest): 62 -> 60 minutes
assert entry1.duration_seconds == 3600
# User2 (30-min up): 62 -> 90 minutes
assert entry2.duration_seconds == 5400
def test_disabling_rounding_uses_exact_time(self, app, authenticated_user):
"""Test that disabling rounding results in exact time tracking"""
with app.app_context():
user = User.query.get(authenticated_user['user_id'])
project = Project.query.get(authenticated_user['project_id'])
# Disable rounding
user.time_rounding_enabled = False
db.session.commit()
# Create entry with odd duration
start_time = datetime(2025, 1, 1, 10, 0, 0)
end_time = start_time + timedelta(minutes=62, seconds=37)
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=start_time,
end_time=end_time
)
db.session.add(entry)
db.session.commit()
# Should be exact: 62 minutes 37 seconds = 3757 seconds
assert entry.duration_seconds == 3757
def test_rounding_with_various_intervals(self, app, authenticated_user):
"""Test that all rounding intervals work correctly"""
with app.app_context():
user = User.query.get(authenticated_user['user_id'])
project = Project.query.get(authenticated_user['project_id'])
# Test duration: 37 minutes
start_time = datetime(2025, 1, 1, 10, 0, 0)
end_time = start_time + timedelta(minutes=37)
test_cases = [
(1, 2220), # No rounding: 37 minutes
(5, 2100), # 5-min: 37 -> 35 minutes
(10, 2400), # 10-min: 37 -> 40 minutes
(15, 2700), # 15-min: 37 -> 45 minutes
(30, 1800), # 30-min: 37 -> 30 minutes
(60, 3600), # 60-min: 37 -> 60 minutes (1 hour)
]
for interval, expected_seconds in test_cases:
user.time_rounding_minutes = interval
user.time_rounding_method = 'nearest'
db.session.commit()
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=start_time,
end_time=end_time
)
db.session.add(entry)
db.session.flush()
assert entry.duration_seconds == expected_seconds, \
f"Failed for {interval}-minute rounding: expected {expected_seconds}, got {entry.duration_seconds}"
db.session.rollback()
def test_rounding_methods_comparison(self, app, authenticated_user):
"""Test that different rounding methods produce different results"""
with app.app_context():
user = User.query.get(authenticated_user['user_id'])
project = Project.query.get(authenticated_user['project_id'])
# Test with 62 minutes and 15-min intervals
start_time = datetime(2025, 1, 1, 10, 0, 0)
end_time = start_time + timedelta(minutes=62)
user.time_rounding_minutes = 15
# Test 'nearest' method
user.time_rounding_method = 'nearest'
db.session.commit()
entry_nearest = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=start_time,
end_time=end_time
)
db.session.add(entry_nearest)
db.session.flush()
# 62 minutes nearest to 15-min interval -> 60 minutes
assert entry_nearest.duration_seconds == 3600
db.session.rollback()
# Test 'up' method
user.time_rounding_method = 'up'
db.session.commit()
entry_up = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=start_time,
end_time=end_time
)
db.session.add(entry_up)
db.session.flush()
# 62 minutes rounded up to 15-min interval -> 75 minutes
assert entry_up.duration_seconds == 4500
db.session.rollback()
# Test 'down' method
user.time_rounding_method = 'down'
db.session.commit()
entry_down = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=start_time,
end_time=end_time
)
db.session.add(entry_down)
db.session.flush()
# 62 minutes rounded down to 15-min interval -> 60 minutes
assert entry_down.duration_seconds == 3600
def test_migration_compatibility(self, app):
"""Test that the feature works after migration"""
with app.app_context():
# Verify that new users get the columns
user = User(username='newuser', role='user')
db.session.add(user)
db.session.commit()
# Check that all fields exist and have correct defaults
assert hasattr(user, 'time_rounding_enabled')
assert hasattr(user, 'time_rounding_minutes')
assert hasattr(user, 'time_rounding_method')
assert user.time_rounding_enabled is True
assert user.time_rounding_minutes == 1
assert user.time_rounding_method == 'nearest'
def test_full_workflow(self, app, authenticated_user):
"""Test complete workflow: set preferences -> create entry -> verify rounding"""
with app.app_context():
user = User.query.get(authenticated_user['user_id'])
project = Project.query.get(authenticated_user['project_id'])
# Step 1: User sets their rounding preferences
user.time_rounding_enabled = True
user.time_rounding_minutes = 10
user.time_rounding_method = 'up'
db.session.commit()
# Step 2: User starts a timer
start_time = datetime(2025, 1, 1, 9, 0, 0)
timer = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=start_time,
end_time=None # Active timer
)
db.session.add(timer)
db.session.commit()
# Verify timer is active
assert timer.is_active is True
assert timer.duration_seconds is None
# Step 3: User stops the timer after 23 minutes
end_time = start_time + timedelta(minutes=23)
timer.stop_timer(end_time=end_time)
# Step 4: Verify the duration was rounded correctly
# With 10-min 'up' rounding, 23 minutes should round up to 30 minutes
assert timer.duration_seconds == 1800 # 30 minutes
assert timer.is_active is False
# Step 5: Verify the entry is queryable with correct rounded duration
db.session.expunge_all()
saved_entry = TimeEntry.query.get(timer.id)
assert saved_entry.duration_seconds == 1800
assert saved_entry.duration_hours == 0.5
class TestEdgeCases:
"""Test edge cases and boundary conditions"""
def test_zero_duration_time_entry(self, app, authenticated_user):
"""Test handling of zero-duration entries"""
with app.app_context():
user = User.query.get(authenticated_user['user_id'])
project = Project.query.get(authenticated_user['project_id'])
# Create entry with same start and end time
time = datetime(2025, 1, 1, 10, 0, 0)
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=time,
end_time=time
)
db.session.add(entry)
db.session.commit()
# Zero duration should stay zero regardless of rounding
assert entry.duration_seconds == 0
def test_very_long_duration(self, app, authenticated_user):
"""Test rounding of very long time entries (multi-day)"""
with app.app_context():
user = User.query.get(authenticated_user['user_id'])
project = Project.query.get(authenticated_user['project_id'])
# 8 hours 7 minutes
start_time = datetime(2025, 1, 1, 9, 0, 0)
end_time = start_time + timedelta(hours=8, minutes=7)
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=start_time,
end_time=end_time
)
db.session.add(entry)
db.session.commit()
# User has 15-min nearest rounding
# 487 minutes -> 485 minutes (rounded down to nearest 15)
assert entry.duration_seconds == 29100 # 485 minutes