mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-05 11:09:55 -06:00
Implement comprehensive time rounding preferences that allow each user to configure how their time entries are rounded when stopping timers. Features: - Per-user rounding settings (independent from global config) - Multiple rounding intervals: 1, 5, 10, 15, 30, 60 minutes - Three rounding methods: nearest, up (ceiling), down (floor) - Enable/disable toggle for flexible time tracking - Real-time preview showing rounding examples - Backward compatible with existing global rounding settings Database Changes: - Add migration 027 with three new user columns: * time_rounding_enabled (Boolean, default: true) * time_rounding_minutes (Integer, default: 1) * time_rounding_method (String, default: 'nearest') Implementation: - Update User model with rounding preference fields - Modify TimeEntry.calculate_duration() to use per-user rounding - Create app/utils/time_rounding.py with core rounding logic - Update user settings route and template with rounding UI - Add comprehensive unit, model, and smoke tests (50+ test cases) UI/UX: - Add "Time Rounding Preferences" section to user settings page - Interactive controls with live example visualization - Descriptive help text and method explanations - Fix navigation: Settings link now correctly points to user.settings - Fix CSRF token in settings form Documentation: - Add comprehensive user guide (docs/TIME_ROUNDING_PREFERENCES.md) - Include API documentation and usage examples - Provide troubleshooting guide and best practices - Add deployment instructions for migration Testing: - Unit tests for rounding logic (tests/test_time_rounding.py) - Model integration tests (tests/test_time_rounding_models.py) - End-to-end smoke tests (tests/test_time_rounding_smoke.py) Fixes: - Correct settings navigation link in user dropdown menu - Fix CSRF token format in user settings template This feature enables flexible billing practices, supports different client requirements, and maintains exact time tracking when needed.
263 lines
10 KiB
Python
263 lines
10 KiB
Python
from datetime import datetime, timedelta, timezone
|
|
from app import db
|
|
from app.config import Config
|
|
from app.utils.timezone import utc_to_local, local_to_utc
|
|
|
|
def local_now():
|
|
"""Get current time in local timezone as naive datetime (for database storage)"""
|
|
from app.utils.timezone import get_timezone_obj
|
|
tz = get_timezone_obj()
|
|
now = datetime.now(tz)
|
|
return now.replace(tzinfo=None)
|
|
|
|
class TimeEntry(db.Model):
|
|
"""Time entry model for manual and automatic time tracking"""
|
|
|
|
__tablename__ = 'time_entries'
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
|
|
project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=False, index=True)
|
|
task_id = db.Column(db.Integer, db.ForeignKey('tasks.id'), nullable=True, index=True)
|
|
start_time = db.Column(db.DateTime, nullable=False, index=True)
|
|
end_time = db.Column(db.DateTime, nullable=True, index=True)
|
|
duration_seconds = db.Column(db.Integer, nullable=True)
|
|
notes = db.Column(db.Text, nullable=True)
|
|
tags = db.Column(db.String(500), nullable=True) # Comma-separated tags
|
|
source = db.Column(db.String(20), default='manual', nullable=False) # 'manual' or 'auto'
|
|
billable = db.Column(db.Boolean, default=True, nullable=False)
|
|
created_at = db.Column(db.DateTime, default=local_now, nullable=False)
|
|
updated_at = db.Column(db.DateTime, default=local_now, onupdate=local_now, nullable=False)
|
|
|
|
# Relationships
|
|
# user and project relationships are defined via backref in their respective models
|
|
# task relationship is defined via backref in Task model
|
|
|
|
def __init__(self, user_id=None, project_id=None, start_time=None, end_time=None, task_id=None, notes=None, tags=None, source='manual', billable=True, duration_seconds=None, **kwargs):
|
|
"""Initialize a TimeEntry instance.
|
|
|
|
Args:
|
|
user_id: ID of the user who created this entry
|
|
project_id: ID of the project this entry is associated with
|
|
start_time: When the time entry started
|
|
end_time: When the time entry ended (None for active timers)
|
|
task_id: Optional task ID
|
|
notes: Optional notes/description
|
|
tags: Optional comma-separated tags
|
|
source: Source of the entry ('manual' or 'auto')
|
|
billable: Whether this entry is billable
|
|
duration_seconds: Optional duration override (usually calculated automatically)
|
|
**kwargs: Additional keyword arguments (for SQLAlchemy compatibility)
|
|
"""
|
|
if user_id is not None:
|
|
self.user_id = user_id
|
|
if project_id is not None:
|
|
self.project_id = project_id
|
|
if task_id is not None:
|
|
self.task_id = task_id
|
|
if start_time is not None:
|
|
self.start_time = start_time
|
|
if end_time is not None:
|
|
self.end_time = end_time
|
|
|
|
self.notes = notes.strip() if notes else None
|
|
self.tags = tags.strip() if tags else None
|
|
self.source = source
|
|
self.billable = billable
|
|
|
|
# Allow manual duration override
|
|
if duration_seconds is not None:
|
|
self.duration_seconds = duration_seconds
|
|
# Otherwise, calculate duration if end time is provided
|
|
elif self.end_time:
|
|
self.calculate_duration()
|
|
|
|
def __repr__(self):
|
|
return f'<TimeEntry {self.id}: {self.user.username} on {self.project.name}>'
|
|
|
|
@property
|
|
def is_active(self):
|
|
"""Check if this is an active timer (no end time)"""
|
|
return self.end_time is None
|
|
|
|
@property
|
|
def duration_hours(self):
|
|
"""Get duration in hours"""
|
|
if not self.duration_seconds:
|
|
return 0
|
|
return round(self.duration_seconds / 3600, 2)
|
|
|
|
@property
|
|
def duration_formatted(self):
|
|
"""Get duration formatted as HH:MM:SS"""
|
|
if not self.duration_seconds:
|
|
return "00:00:00"
|
|
|
|
hours = self.duration_seconds // 3600
|
|
minutes = (self.duration_seconds % 3600) // 60
|
|
seconds = self.duration_seconds % 60
|
|
|
|
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
|
|
|
@property
|
|
def tag_list(self):
|
|
"""Get tags as a list"""
|
|
if not self.tags:
|
|
return []
|
|
return [tag.strip() for tag in self.tags.split(',') if tag.strip()]
|
|
|
|
@property
|
|
def current_duration_seconds(self):
|
|
"""Calculate current duration for active timers"""
|
|
if self.end_time:
|
|
return self.duration_seconds or 0
|
|
|
|
# For active timers, calculate from start time to now
|
|
# Since we store everything in local timezone, we can work with naive datetimes
|
|
# as long as we treat them as local time
|
|
|
|
# Get current time in local timezone (naive, matching database storage)
|
|
now_local = local_now()
|
|
|
|
# Calculate duration (both times are treated as local time)
|
|
duration = now_local - self.start_time
|
|
return int(duration.total_seconds())
|
|
|
|
def calculate_duration(self):
|
|
"""Calculate and set duration in seconds with rounding"""
|
|
if not self.end_time:
|
|
return
|
|
|
|
# Since we store everything in local timezone, we can work with naive datetimes
|
|
# as long as we treat them as local time
|
|
|
|
# Calculate raw duration (both times are treated as local time)
|
|
duration = self.end_time - self.start_time
|
|
raw_seconds = int(duration.total_seconds())
|
|
|
|
# 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:
|
|
# 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"""
|
|
if self.end_time:
|
|
raise ValueError("Timer is already stopped")
|
|
|
|
# Use local timezone for consistency with database storage
|
|
if end_time:
|
|
self.end_time = end_time
|
|
else:
|
|
self.end_time = local_now()
|
|
|
|
self.calculate_duration()
|
|
self.updated_at = local_now()
|
|
|
|
db.session.commit()
|
|
|
|
def update_notes(self, notes):
|
|
"""Update notes for this entry"""
|
|
self.notes = notes.strip() if notes else None
|
|
self.updated_at = local_now()
|
|
db.session.commit()
|
|
|
|
def update_tags(self, tags):
|
|
"""Update tags for this entry"""
|
|
self.tags = tags.strip() if tags else None
|
|
self.updated_at = local_now()
|
|
db.session.commit()
|
|
|
|
def set_billable(self, billable):
|
|
"""Set billable status"""
|
|
self.billable = billable
|
|
self.updated_at = local_now()
|
|
db.session.commit()
|
|
|
|
def to_dict(self):
|
|
"""Convert time entry to dictionary for API responses"""
|
|
return {
|
|
'id': self.id,
|
|
'user_id': self.user_id,
|
|
'project_id': self.project_id,
|
|
'task_id': self.task_id,
|
|
'start_time': self.start_time.isoformat() if self.start_time else None,
|
|
'end_time': self.end_time.isoformat() if self.end_time else None,
|
|
'duration_seconds': self.duration_seconds,
|
|
'duration_hours': self.duration_hours,
|
|
'duration_formatted': self.duration_formatted,
|
|
'notes': self.notes,
|
|
'tags': self.tags,
|
|
'tag_list': self.tag_list,
|
|
'source': self.source,
|
|
'billable': self.billable,
|
|
'is_active': self.is_active,
|
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
|
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
|
'user': self.user.username if self.user else None,
|
|
'project': self.project.name if self.project else None,
|
|
'task': self.task.name if self.task else None
|
|
}
|
|
|
|
@classmethod
|
|
def get_active_timers(cls):
|
|
"""Get all active timers"""
|
|
return cls.query.filter_by(end_time=None).all()
|
|
|
|
@classmethod
|
|
def get_user_active_timer(cls, user_id):
|
|
"""Get active timer for a specific user"""
|
|
return cls.query.filter_by(user_id=user_id, end_time=None).first()
|
|
|
|
@classmethod
|
|
def get_entries_for_period(cls, start_date=None, end_date=None, user_id=None, project_id=None):
|
|
"""Get time entries for a specific period with optional filters"""
|
|
query = cls.query.filter(cls.end_time.isnot(None))
|
|
|
|
if start_date:
|
|
query = query.filter(cls.start_time >= start_date)
|
|
|
|
if end_date:
|
|
query = query.filter(cls.start_time <= end_date)
|
|
|
|
if user_id:
|
|
query = query.filter(cls.user_id == user_id)
|
|
|
|
if project_id:
|
|
query = query.filter(cls.project_id == project_id)
|
|
|
|
return query.order_by(cls.start_time.desc()).all()
|
|
|
|
@classmethod
|
|
def get_total_hours_for_period(cls, start_date=None, end_date=None, user_id=None, project_id=None, billable_only=False):
|
|
"""Calculate total hours for a period with optional filters"""
|
|
query = db.session.query(db.func.sum(cls.duration_seconds))
|
|
|
|
if start_date:
|
|
query = query.filter(cls.start_time >= start_date)
|
|
|
|
if end_date:
|
|
query = query.filter(cls.start_time <= end_date)
|
|
|
|
if user_id:
|
|
query = query.filter(cls.user_id == user_id)
|
|
|
|
if project_id:
|
|
query = query.filter(cls.project_id == project_id)
|
|
|
|
if billable_only:
|
|
query = query.filter(cls.billable == True)
|
|
|
|
total_seconds = query.scalar() or 0
|
|
return round(total_seconds / 3600, 2)
|