diff --git a/README.md b/README.md index 8c99197..8912f64 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,11 @@ A comprehensive web-based time tracking application built with Flask, featuring - **🔐 Multi-User Support** - Role-based access control with admin and user roles - **🐳 Docker Ready** - Multiple deployment options with automatic database migration - **📱 Mobile Optimized** - Responsive design that works perfectly on all devices + - **🎯 Focus Mode (Pomodoro)** - Start focus sessions with configurable cycles and view summaries + - **📈 Estimates vs Actuals** - Project estimates, burn-down charts, and budget threshold alerts + - **🔁 Recurring Time Blocks** - Create templates for common tasks and auto-generate entries + - **🏷️ Tagging & Saved Filters** - Add tags to entries and reuse saved filters across views + - **💰 Rate Overrides** - Billable rate overrides per project/member for precise invoicing ## 📸 Screenshots diff --git a/app/models/__init__.py b/app/models/__init__.py index 5d84ca9..01b1685 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -7,5 +7,12 @@ from .invoice import Invoice, InvoiceItem from .client import Client from .task_activity import TaskActivity from .comment import Comment +from .focus_session import FocusSession +from .recurring_block import RecurringBlock +from .rate_override import RateOverride +from .saved_filter import SavedFilter -__all__ = ['User', 'Project', 'TimeEntry', 'Task', 'Settings', 'Invoice', 'InvoiceItem', 'Client', 'TaskActivity', 'Comment'] +__all__ = [ + 'User', 'Project', 'TimeEntry', 'Task', 'Settings', 'Invoice', 'InvoiceItem', 'Client', 'TaskActivity', 'Comment', + 'FocusSession', 'RecurringBlock', 'RateOverride', 'SavedFilter' +] diff --git a/app/models/focus_session.py b/app/models/focus_session.py new file mode 100644 index 0000000..30e5a83 --- /dev/null +++ b/app/models/focus_session.py @@ -0,0 +1,58 @@ +from datetime import datetime +from app import db + + +class FocusSession(db.Model): + """Pomodoro-style focus session metadata linked to a time entry. + + Tracks configuration and outcomes for a single focus session so we can + provide summaries independent of raw time entries. + """ + + __tablename__ = 'focus_sessions' + + 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=True, index=True) + task_id = db.Column(db.Integer, db.ForeignKey('tasks.id'), nullable=True, index=True) + time_entry_id = db.Column(db.Integer, db.ForeignKey('time_entries.id'), nullable=True, index=True) + + # Session timing + started_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + ended_at = db.Column(db.DateTime, nullable=True) + + # Pomodoro configuration (minutes) + pomodoro_length = db.Column(db.Integer, nullable=False, default=25) + short_break_length = db.Column(db.Integer, nullable=False, default=5) + long_break_length = db.Column(db.Integer, nullable=False, default=15) + long_break_interval = db.Column(db.Integer, nullable=False, default=4) + + # Outcomes + cycles_completed = db.Column(db.Integer, nullable=False, default=0) + interruptions = db.Column(db.Integer, nullable=False, default=0) + notes = db.Column(db.Text, nullable=True) + + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + def to_dict(self): + return { + 'id': self.id, + 'user_id': self.user_id, + 'project_id': self.project_id, + 'task_id': self.task_id, + 'time_entry_id': self.time_entry_id, + 'started_at': self.started_at.isoformat() if self.started_at else None, + 'ended_at': self.ended_at.isoformat() if self.ended_at else None, + 'pomodoro_length': self.pomodoro_length, + 'short_break_length': self.short_break_length, + 'long_break_length': self.long_break_length, + 'long_break_interval': self.long_break_interval, + 'cycles_completed': self.cycles_completed, + 'interruptions': self.interruptions, + 'notes': self.notes, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None, + } + + diff --git a/app/models/project.py b/app/models/project.py index cb3ba18..70fadf9 100644 --- a/app/models/project.py +++ b/app/models/project.py @@ -15,6 +15,10 @@ class Project(db.Model): hourly_rate = db.Column(db.Numeric(9, 2), nullable=True) billing_ref = db.Column(db.String(100), nullable=True) status = db.Column(db.String(20), default='active', nullable=False) # 'active' or 'archived' + # Estimates & budgets + estimated_hours = db.Column(db.Float, nullable=True) + budget_amount = db.Column(db.Numeric(10, 2), nullable=True) + budget_threshold_percent = db.Column(db.Integer, nullable=False, default=80) # alert when exceeded created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) @@ -103,6 +107,38 @@ class Project(db.Model): if not self.billable or not self.hourly_rate: return 0.0 return float(self.total_billable_hours) * float(self.hourly_rate) + + @property + def actual_hours(self): + """Alias for total hours for clarity in estimates vs actuals.""" + return self.total_hours + + @property + def budget_consumed_amount(self): + """Compute consumed budget using effective rate logic when available. + + Falls back to project.hourly_rate if no overrides are present. + """ + try: + from .rate_override import RateOverride + hours = self.total_billable_hours + # Use project-level override if present, else project rate + rate = RateOverride.resolve_rate(self, user_id=None) + return float(hours * float(rate)) + except Exception: + if self.hourly_rate: + return float(self.total_billable_hours * float(self.hourly_rate)) + return 0.0 + + @property + def budget_threshold_exceeded(self): + if not self.budget_amount: + return False + try: + threshold = (self.budget_threshold_percent or 0) / 100.0 + return self.budget_consumed_amount >= float(self.budget_amount) * threshold + except Exception: + return False def get_entries_by_user(self, user_id=None, start_date=None, end_date=None): """Get time entries for this project, optionally filtered by user and date range""" @@ -174,9 +210,14 @@ class Project(db.Model): 'hourly_rate': float(self.hourly_rate) if self.hourly_rate else None, 'billing_ref': self.billing_ref, 'status': self.status, + 'estimated_hours': self.estimated_hours, + 'budget_amount': float(self.budget_amount) if self.budget_amount else None, + 'budget_threshold_percent': self.budget_threshold_percent, 'created_at': self.created_at.isoformat() if self.created_at else None, 'updated_at': self.updated_at.isoformat() if self.updated_at else None, 'total_hours': self.total_hours, 'total_billable_hours': self.total_billable_hours, - 'estimated_cost': float(self.estimated_cost) if self.estimated_cost else None + 'estimated_cost': float(self.estimated_cost) if self.estimated_cost else None, + 'budget_consumed_amount': self.budget_consumed_amount, + 'budget_threshold_exceeded': self.budget_threshold_exceeded, } diff --git a/app/models/rate_override.py b/app/models/rate_override.py new file mode 100644 index 0000000..ce361de --- /dev/null +++ b/app/models/rate_override.py @@ -0,0 +1,68 @@ +from datetime import datetime +from decimal import Decimal +from app import db + + +class RateOverride(db.Model): + """Billable rate overrides per project and optionally per user. + + Resolution precedence (highest to lowest) for effective hourly rate: + - RateOverride for (project_id, user_id) + - RateOverride for (project_id, user_id=None) # project default override + - Project.hourly_rate + - Client.default_hourly_rate + - 0 + """ + + __tablename__ = 'rate_overrides' + + id = db.Column(db.Integer, primary_key=True) + project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=False, index=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True, index=True) + hourly_rate = db.Column(db.Numeric(9, 2), nullable=False) + effective_from = db.Column(db.Date, nullable=True) + effective_to = db.Column(db.Date, nullable=True) + + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + __table_args__ = ( + db.UniqueConstraint('project_id', 'user_id', 'effective_from', name='ux_rate_override_unique_window'), + ) + + @classmethod + def resolve_rate(cls, project, user_id=None, on_date=None): + """Resolve effective hourly rate for a project/user at a given date.""" + if not project: + return Decimal('0') + + # Step 1: specific user override + q = cls.query.filter_by(project_id=project.id, user_id=user_id) + if on_date: + q = q.filter((cls.effective_from.is_(None) | (cls.effective_from <= on_date)) & (cls.effective_to.is_(None) | (cls.effective_to >= on_date))) + user_ovr = q.order_by(cls.effective_from.desc().nullslast()).first() + if user_ovr: + return Decimal(user_ovr.hourly_rate) + + # Step 2: project-level override + q = cls.query.filter_by(project_id=project.id, user_id=None) + if on_date: + q = q.filter((cls.effective_from.is_(None) | (cls.effective_from <= on_date)) & (cls.effective_to.is_(None) | (cls.effective_to >= on_date))) + proj_ovr = q.order_by(cls.effective_from.desc().nullslast()).first() + if proj_ovr: + return Decimal(proj_ovr.hourly_rate) + + # Step 3: project rate + if project.hourly_rate: + return Decimal(project.hourly_rate) + + # Step 4: client default + try: + if project.client_obj and project.client_obj.default_hourly_rate: + return Decimal(project.client_obj.default_hourly_rate) + except Exception: + pass + + return Decimal('0') + + diff --git a/app/models/recurring_block.py b/app/models/recurring_block.py new file mode 100644 index 0000000..e550fae --- /dev/null +++ b/app/models/recurring_block.py @@ -0,0 +1,69 @@ +from datetime import datetime +from app import db + + +class RecurringBlock(db.Model): + """Recurring time block template to generate time entries on a schedule. + + Supports weekly recurrences with selected weekdays, start/end times, and optional + end date. Generation logic will live in a scheduler/route that expands these + templates into concrete `TimeEntry` rows. + """ + + __tablename__ = 'recurring_blocks' + + 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) + + name = db.Column(db.String(200), nullable=False) + + # Scheduling fields + # 'weekly' for now; room to add 'daily', 'monthly' later + recurrence = db.Column(db.String(20), nullable=False, default='weekly') + # Weekdays CSV: e.g., "mon,tue,wed"; canonical lower 3-letter names + weekdays = db.Column(db.String(50), nullable=True) + # Time window in local time: "HH:MM" strings + start_time_local = db.Column(db.String(5), nullable=False) # 09:00 + end_time_local = db.Column(db.String(5), nullable=False) # 11:00 + + # Activation window + starts_on = db.Column(db.Date, nullable=True) + ends_on = db.Column(db.Date, nullable=True) + is_active = db.Column(db.Boolean, nullable=False, default=True) + + # Entry details + notes = db.Column(db.Text, nullable=True) + tags = db.Column(db.String(500), nullable=True) + billable = db.Column(db.Boolean, nullable=False, default=True) + + # Tracking last generation to avoid duplicates + last_generated_at = db.Column(db.DateTime, nullable=True) + + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + def to_dict(self): + return { + 'id': self.id, + 'user_id': self.user_id, + 'project_id': self.project_id, + 'task_id': self.task_id, + 'name': self.name, + 'recurrence': self.recurrence, + 'weekdays': self.weekdays, + 'start_time_local': self.start_time_local, + 'end_time_local': self.end_time_local, + 'starts_on': self.starts_on.isoformat() if self.starts_on else None, + 'ends_on': self.ends_on.isoformat() if self.ends_on else None, + 'is_active': self.is_active, + 'notes': self.notes, + 'tags': self.tags, + 'billable': self.billable, + 'last_generated_at': self.last_generated_at.isoformat() if self.last_generated_at else None, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None, + } + + diff --git a/app/models/saved_filter.py b/app/models/saved_filter.py new file mode 100644 index 0000000..8ca9a23 --- /dev/null +++ b/app/models/saved_filter.py @@ -0,0 +1,41 @@ +from datetime import datetime +from app import db + + +class SavedFilter(db.Model): + """User-defined saved filters for reuse across views. + + Stores JSON payload with supported keys like project_id, user_id, date ranges, + tags, billable, status, etc. + """ + + __tablename__ = 'saved_filters' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + name = db.Column(db.String(200), nullable=False) + scope = db.Column(db.String(50), nullable=False, default='global') # e.g., 'time', 'projects', 'tasks', 'reports' + payload = db.Column(db.JSON, nullable=False, default={}) + + is_shared = db.Column(db.Boolean, nullable=False, default=False) + + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + __table_args__ = ( + db.UniqueConstraint('user_id', 'name', 'scope', name='ux_saved_filter_user_name_scope'), + ) + + def to_dict(self): + return { + 'id': self.id, + 'user_id': self.user_id, + 'name': self.name, + 'scope': self.scope, + 'payload': self.payload, + 'is_shared': self.is_shared, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None, + } + + diff --git a/app/routes/api.py b/app/routes/api.py index 2d96f5f..6d662cf 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -1,7 +1,7 @@ from flask import Blueprint, jsonify, request, current_app, send_from_directory from flask_login import login_required, current_user from app import db, socketio -from app.models import User, Project, TimeEntry, Settings, Task +from app.models import User, Project, TimeEntry, Settings, Task, FocusSession, RecurringBlock, RateOverride, SavedFilter from datetime import datetime, timedelta from app.utils.db import safe_commit from app.utils.timezone import parse_local_datetime, utc_to_local @@ -266,8 +266,24 @@ def get_entries(): per_page = request.args.get('per_page', 20, type=int) user_id = request.args.get('user_id', type=int) project_id = request.args.get('project_id', type=int) + tag = (request.args.get('tag') or '').strip() + saved_filter_id = request.args.get('saved_filter_id', type=int) query = TimeEntry.query.filter(TimeEntry.end_time.isnot(None)) + + # Apply saved filter if provided + if saved_filter_id: + filt = SavedFilter.query.get(saved_filter_id) + if filt and (filt.user_id == current_user.id or (filt.is_shared and current_user.is_admin)): + payload = filt.payload or {} + if 'project_id' in payload: + query = query.filter(TimeEntry.project_id == int(payload['project_id'])) + if 'user_id' in payload and current_user.is_admin: + query = query.filter(TimeEntry.user_id == int(payload['user_id'])) + if 'billable' in payload: + query = query.filter(TimeEntry.billable == bool(payload['billable'])) + if 'tag' in payload and payload['tag']: + query = query.filter(TimeEntry.tags.ilike(f"%{payload['tag']}%")) # Filter by user (if admin or own entries) if user_id and current_user.is_admin: @@ -278,6 +294,11 @@ def get_entries(): # Filter by project if project_id: query = query.filter(TimeEntry.project_id == project_id) + + # Filter by tag (simple contains search on comma-separated tags) + if tag: + like = f"%{tag}%" + query = query.filter(TimeEntry.tags.ilike(like)) entries = query.order_by(TimeEntry.start_time.desc()).paginate( page=page, @@ -301,6 +322,252 @@ def get_entries(): 'has_prev': entries.has_prev }) +@api_bp.route('/api/projects//burndown') +@login_required +def project_burndown(project_id): + """Return burn-down data for a given project. + + Produces daily cumulative actual hours vs estimated hours line. + """ + project = Project.query.get_or_404(project_id) + # Permission: any authenticated can view if they have entries in project or are admin + if not current_user.is_admin: + has_entries = db.session.query(TimeEntry.id).filter_by(user_id=current_user.id, project_id=project_id).first() + if not has_entries: + return jsonify({'error': 'Access denied'}), 403 + + # Date range: last 30 days up to today + end_date = datetime.utcnow().date() + start_date = end_date - timedelta(days=29) + + # Fetch entries in range + entries = ( + TimeEntry.query + .filter(TimeEntry.project_id == project_id) + .filter(TimeEntry.end_time.isnot(None)) + .filter(TimeEntry.start_time >= datetime.combine(start_date, datetime.min.time())) + .filter(TimeEntry.start_time <= datetime.combine(end_date, datetime.max.time())) + .order_by(TimeEntry.start_time.asc()) + .all() + ) + + # Build daily buckets + labels = [] + actual_cumulative = [] + day_map = {} + cur = start_date + while cur <= end_date: + labels.append(cur.isoformat()) + day_map[cur.isoformat()] = 0.0 + cur = cur + timedelta(days=1) + + for e in entries: + d = e.start_time.date().isoformat() + day_map[d] = day_map.get(d, 0.0) + (e.duration_seconds or 0) / 3600.0 + + running = 0.0 + for d in labels: + running += day_map.get(d, 0.0) + actual_cumulative.append(round(running, 2)) + + # Estimated line: flat line of project.estimated_hours + estimated = float(project.estimated_hours or 0) + estimate_series = [estimated for _ in labels] + + return jsonify({ + 'labels': labels, + 'actual_cumulative': actual_cumulative, + 'estimated': estimate_series, + 'estimated_hours': estimated, + }) + +@api_bp.route('/api/focus-sessions/start', methods=['POST']) +@login_required +def start_focus_session(): + data = request.get_json() or {} + project_id = data.get('project_id') + task_id = data.get('task_id') + pomodoro_length = int(data.get('pomodoro_length') or 25) + short_break_length = int(data.get('short_break_length') or 5) + long_break_length = int(data.get('long_break_length') or 15) + long_break_interval = int(data.get('long_break_interval') or 4) + link_active_timer = bool(data.get('link_active_timer', True)) + + time_entry_id = None + if link_active_timer and current_user.active_timer: + time_entry_id = current_user.active_timer.id + + fs = FocusSession( + user_id=current_user.id, + project_id=project_id, + task_id=task_id, + time_entry_id=time_entry_id, + pomodoro_length=pomodoro_length, + short_break_length=short_break_length, + long_break_length=long_break_length, + long_break_interval=long_break_interval, + ) + db.session.add(fs) + if not safe_commit('start_focus_session', {'user_id': current_user.id}): + return jsonify({'error': 'Database error while starting focus session'}), 500 + + return jsonify({'success': True, 'session': fs.to_dict()}) + +@api_bp.route('/api/focus-sessions/finish', methods=['POST']) +@login_required +def finish_focus_session(): + data = request.get_json() or {} + session_id = data.get('session_id') + if not session_id: + return jsonify({'error': 'session_id is required'}), 400 + fs = FocusSession.query.get_or_404(session_id) + if fs.user_id != current_user.id and not current_user.is_admin: + return jsonify({'error': 'Access denied'}), 403 + + fs.ended_at = datetime.utcnow() + fs.cycles_completed = int(data.get('cycles_completed') or 0) + fs.interruptions = int(data.get('interruptions') or 0) + notes = (data.get('notes') or '').strip() + fs.notes = notes or fs.notes + if not safe_commit('finish_focus_session', {'session_id': fs.id}): + return jsonify({'error': 'Database error while finishing focus session'}), 500 + return jsonify({'success': True, 'session': fs.to_dict()}) + +@api_bp.route('/api/focus-sessions/summary') +@login_required +def focus_sessions_summary(): + """Return simple summary counts for recent focus sessions for the current user.""" + days = int(request.args.get('days', 7)) + since = datetime.utcnow() - timedelta(days=days) + q = FocusSession.query.filter(FocusSession.user_id == current_user.id, FocusSession.started_at >= since) + sessions = q.order_by(FocusSession.started_at.desc()).all() + total = len(sessions) + cycles = sum(s.cycles_completed or 0 for s in sessions) + interrupts = sum(s.interruptions or 0 for s in sessions) + return jsonify({'total_sessions': total, 'cycles_completed': cycles, 'interruptions': interrupts}) + +@api_bp.route('/api/recurring-blocks', methods=['GET', 'POST']) +@login_required +def recurring_blocks_list_create(): + if request.method == 'GET': + blocks = RecurringBlock.query.filter_by(user_id=current_user.id).order_by(RecurringBlock.created_at.desc()).all() + return jsonify({'blocks': [b.to_dict() for b in blocks]}) + + data = request.get_json() or {} + name = (data.get('name') or '').strip() + project_id = data.get('project_id') + task_id = data.get('task_id') + recurrence = (data.get('recurrence') or 'weekly').strip() + weekdays = (data.get('weekdays') or '').strip() + start_time_local = (data.get('start_time_local') or '').strip() + end_time_local = (data.get('end_time_local') or '').strip() + starts_on = data.get('starts_on') + ends_on = data.get('ends_on') + is_active = bool(data.get('is_active', True)) + notes = (data.get('notes') or '').strip() or None + tags = (data.get('tags') or '').strip() or None + billable = bool(data.get('billable', True)) + + if not all([name, project_id, start_time_local, end_time_local]): + return jsonify({'error': 'name, project_id, start_time_local, end_time_local are required'}), 400 + + block = RecurringBlock( + user_id=current_user.id, + project_id=project_id, + task_id=task_id, + name=name, + recurrence=recurrence, + weekdays=weekdays, + start_time_local=start_time_local, + end_time_local=end_time_local, + is_active=is_active, + notes=notes, + tags=tags, + billable=billable, + ) + + # Optional dates + try: + if starts_on: + block.starts_on = datetime.fromisoformat(starts_on).date() + if ends_on: + block.ends_on = datetime.fromisoformat(ends_on).date() + except Exception: + return jsonify({'error': 'Invalid starts_on/ends_on date format'}), 400 + + db.session.add(block) + if not safe_commit('create_recurring_block', {'user_id': current_user.id}): + return jsonify({'error': 'Database error while creating recurring block'}), 500 + return jsonify({'success': True, 'block': block.to_dict()}) + +@api_bp.route('/api/recurring-blocks/', methods=['PUT', 'DELETE']) +@login_required +def recurring_block_update_delete(block_id): + block = RecurringBlock.query.get_or_404(block_id) + if block.user_id != current_user.id and not current_user.is_admin: + return jsonify({'error': 'Access denied'}), 403 + + if request.method == 'DELETE': + db.session.delete(block) + if not safe_commit('delete_recurring_block', {'id': block.id}): + return jsonify({'error': 'Database error while deleting recurring block'}), 500 + return jsonify({'success': True}) + + data = request.get_json() or {} + for field in ['name', 'recurrence', 'weekdays', 'start_time_local', 'end_time_local', 'notes', 'tags']: + if field in data: + setattr(block, field, (data.get(field) or '').strip()) + for field in ['project_id', 'task_id']: + if field in data: + setattr(block, field, data.get(field)) + if 'is_active' in data: + block.is_active = bool(data.get('is_active')) + if 'billable' in data: + block.billable = bool(data.get('billable')) + try: + if 'starts_on' in data: + block.starts_on = datetime.fromisoformat(data.get('starts_on')).date() if data.get('starts_on') else None + if 'ends_on' in data: + block.ends_on = datetime.fromisoformat(data.get('ends_on')).date() if data.get('ends_on') else None + except Exception: + return jsonify({'error': 'Invalid starts_on/ends_on date format'}), 400 + + if not safe_commit('update_recurring_block', {'id': block.id}): + return jsonify({'error': 'Database error while updating recurring block'}), 500 + return jsonify({'success': True, 'block': block.to_dict()}) + +@api_bp.route('/api/saved-filters', methods=['GET', 'POST']) +@login_required +def saved_filters_list_create(): + if request.method == 'GET': + scope = (request.args.get('scope') or 'global').strip() + items = SavedFilter.query.filter_by(user_id=current_user.id, scope=scope).order_by(SavedFilter.name.asc()).all() + return jsonify({'filters': [f.to_dict() for f in items]}) + + data = request.get_json() or {} + name = (data.get('name') or '').strip() + scope = (data.get('scope') or 'global').strip() + payload = data.get('payload') or {} + is_shared = bool(data.get('is_shared', False)) + if not name: + return jsonify({'error': 'name is required'}), 400 + filt = SavedFilter(user_id=current_user.id, name=name, scope=scope, payload=payload, is_shared=is_shared) + db.session.add(filt) + if not safe_commit('create_saved_filter', {'name': name, 'scope': scope}): + return jsonify({'error': 'Database error while creating saved filter'}), 500 + return jsonify({'success': True, 'filter': filt.to_dict()}) + +@api_bp.route('/api/saved-filters/', methods=['DELETE']) +@login_required +def delete_saved_filter(filter_id): + filt = SavedFilter.query.get_or_404(filter_id) + if filt.user_id != current_user.id and not current_user.is_admin: + return jsonify({'error': 'Access denied'}), 403 + db.session.delete(filt) + if not safe_commit('delete_saved_filter', {'id': filt.id}): + return jsonify({'error': 'Database error while deleting saved filter'}), 500 + return jsonify({'success': True}) + @api_bp.route('/api/entries', methods=['POST']) @login_required def create_entry(): diff --git a/app/routes/invoices.py b/app/routes/invoices.py index dacb449..ec95114 100644 --- a/app/routes/invoices.py +++ b/app/routes/invoices.py @@ -2,7 +2,7 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash, from flask_babel import gettext as _ from flask_login import login_required, current_user from app import db -from app.models import User, Project, TimeEntry, Invoice, InvoiceItem, Settings +from app.models import User, Project, TimeEntry, Invoice, InvoiceItem, Settings, RateOverride from datetime import datetime, timedelta, date from decimal import Decimal, InvalidOperation import io @@ -349,8 +349,8 @@ def generate_from_time(invoice_id): # Create invoice items for group in grouped_entries.values(): - # Use project hourly rate or default - hourly_rate = invoice.project.hourly_rate or Decimal('0') + # Resolve effective rate (project override -> project rate -> client default) + hourly_rate = RateOverride.resolve_rate(invoice.project) item = InvoiceItem( invoice_id=invoice.id, diff --git a/app/utils/cli.py b/app/utils/cli.py index 8c94cd1..20f71b4 100644 --- a/app/utils/cli.py +++ b/app/utils/cli.py @@ -2,7 +2,7 @@ import os import click from flask.cli import with_appcontext from app import db -from app.models import User, Project, TimeEntry, Settings, Client +from app.models import User, Project, TimeEntry, Settings, Client, RecurringBlock from datetime import datetime, timedelta import shutil from app.utils.backup import create_backup, restore_backup @@ -166,3 +166,69 @@ def register_cli_commands(app): except Exception as e: click.echo(f"Error getting migration history: {e}") click.echo("Make sure Flask-Migrate is properly initialized") + + @app.cli.command() + @with_appcontext + @click.option('--days', default=7, help='Generate entries for the next N days') + def generate_recurring(days): + """Expand active recurring time blocks into concrete time entries for the next N days.""" + from datetime import date, time + from app.utils.timezone import get_timezone_obj + tz = get_timezone_obj() + + today = datetime.now(tz).date() + end = today + timedelta(days=int(days)) + weekday_map = { 'mon':0, 'tue':1, 'wed':2, 'thu':3, 'fri':4, 'sat':5, 'sun':6 } + + blocks = RecurringBlock.query.filter_by(is_active=True).all() + created = 0 + for b in blocks: + start_date = b.starts_on or today + stop_date = b.ends_on or end + window_start = max(today, start_date) + window_end = min(end, stop_date) + if window_end < window_start: + continue + weekdays = [(w.strip().lower()) for w in (b.weekdays or '').split(',') if w.strip()] + weekday_nums = { weekday_map[w] for w in weekdays if w in weekday_map } + cur = window_start + while cur <= window_end: + if not weekday_nums or cur.weekday() in weekday_nums: + try: + sh, sm = [int(x) for x in b.start_time_local.split(':')] + eh, em = [int(x) for x in b.end_time_local.split(':')] + except Exception: + cur += timedelta(days=1) + continue + # Build naive datetimes in local tz then drop tzinfo for storage convention + start_dt = datetime(cur.year, cur.month, cur.day, sh, sm) + end_dt = datetime(cur.year, cur.month, cur.day, eh, em) + if end_dt <= start_dt: + cur += timedelta(days=1) + continue + # Avoid duplicates: skip if overlapping entry exists for same user/project in this window + exists = ( + TimeEntry.query + .filter(TimeEntry.user_id == b.user_id, TimeEntry.project_id == b.project_id) + .filter(TimeEntry.start_time == start_dt, TimeEntry.end_time == end_dt) + .first() + ) + if exists: + cur += timedelta(days=1) + continue + te = TimeEntry( + user_id=b.user_id, + project_id=b.project_id, + task_id=b.task_id, + start_time=start_dt, + end_time=end_dt, + notes=b.notes, + tags=b.tags, + source='manual', + billable=b.billable, + ) + db.session.add(te) + created += 1 + cur += timedelta(days=1) + db.session.commit() + click.echo(f"Recurring generation complete. Created {created} entries.") diff --git a/docs/ENHANCED_DATABASE_STARTUP.md b/docs/ENHANCED_DATABASE_STARTUP.md index 3a93a4c..2208f91 100644 --- a/docs/ENHANCED_DATABASE_STARTUP.md +++ b/docs/ENHANCED_DATABASE_STARTUP.md @@ -33,6 +33,10 @@ This script provides comprehensive database setup: - `settings` - Application configuration and company branding - `invoices` - Invoice management - `invoice_items` - Individual invoice line items + - `focus_sessions` - Pomodoro/focus session summaries linked to `time_entries` + - `recurring_blocks` - Templates for recurring time blocks to auto-create entries + - `rate_overrides` - Per-project and per-user billable rate overrides + - `saved_filters` - User-defined saved filters payloads for reusable queries #### Key Features diff --git a/docs/INVOICE_INTERFACE_IMPROVEMENTS.md b/docs/INVOICE_INTERFACE_IMPROVEMENTS.md index f7f3a45..baee631 100644 --- a/docs/INVOICE_INTERFACE_IMPROVEMENTS.md +++ b/docs/INVOICE_INTERFACE_IMPROVEMENTS.md @@ -115,6 +115,12 @@ The invoice feature interface has been significantly improved to provide a more ## Technical Improvements +### **Effective Rate Resolution** +- Invoices generated from time entries now use a precedence order for hourly rates: + 1) project+user `rate_overrides` record; 2) project-only `rate_overrides` record; + 3) `Project.hourly_rate`; 4) `Client.default_hourly_rate`. +- This allows granular billable rate overrides per project/member. + ### **CSS Enhancements** - Modern shadow system with `shadow-sm` and `border-0` - Consistent spacing using Bootstrap utilities diff --git a/docs/TASK_MANAGEMENT.md b/docs/TASK_MANAGEMENT.md index 5f72217..fccee69 100644 --- a/docs/TASK_MANAGEMENT.md +++ b/docs/TASK_MANAGEMENT.md @@ -168,6 +168,14 @@ If you encounter database-related errors: 3. Verify all required tables exist 4. Contact system administrator if issues persist +## Recurring Time Blocks + +The system supports recurring time block templates via the `recurring_blocks` table. + +- Fields: `name`, `recurrence` (weekly), `weekdays` (e.g., `mon,tue`), `start_time_local`, `end_time_local`, optional `starts_on`/`ends_on`. +- Blocks can include `notes`, `tags`, and `billable` flag and are user-owned. +- API endpoints allow CRUD operations; a scheduler can periodically expand these into concrete `time_entries`. + ## Future Enhancements Planned improvements for Task Management: diff --git a/migrations/versions/016_add_focus_recurring_rates_filters_and_project_budget.py b/migrations/versions/016_add_focus_recurring_rates_filters_and_project_budget.py new file mode 100644 index 0000000..34b179d --- /dev/null +++ b/migrations/versions/016_add_focus_recurring_rates_filters_and_project_budget.py @@ -0,0 +1,137 @@ +"""add focus sessions, recurring blocks, rate overrides, saved filters, and project budget fields + +Revision ID: 016 +Revises: 015 +Create Date: 2025-10-06 00:00:00 +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '016' +down_revision = '015' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + existing_tables = set(inspector.get_table_names()) + + # projects: add estimates/budget fields if missing + if 'projects' in existing_tables: + cols = {c['name'] for c in inspector.get_columns('projects')} + with op.batch_alter_table('projects') as batch: + if 'estimated_hours' not in cols: + batch.add_column(sa.Column('estimated_hours', sa.Float(), nullable=True)) + if 'budget_amount' not in cols: + batch.add_column(sa.Column('budget_amount', sa.Numeric(10, 2), nullable=True)) + if 'budget_threshold_percent' not in cols: + batch.add_column(sa.Column('budget_threshold_percent', sa.Integer(), nullable=False, server_default='80')) + + # focus_sessions table + if 'focus_sessions' not in existing_tables: + op.create_table( + 'focus_sessions', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False, index=True), + sa.Column('project_id', sa.Integer(), sa.ForeignKey('projects.id'), nullable=True, index=True), + sa.Column('task_id', sa.Integer(), sa.ForeignKey('tasks.id'), nullable=True, index=True), + sa.Column('time_entry_id', sa.Integer(), sa.ForeignKey('time_entries.id'), nullable=True, index=True), + sa.Column('started_at', sa.DateTime(), nullable=False), + sa.Column('ended_at', sa.DateTime(), nullable=True), + sa.Column('pomodoro_length', sa.Integer(), nullable=False, server_default='25'), + sa.Column('short_break_length', sa.Integer(), nullable=False, server_default='5'), + sa.Column('long_break_length', sa.Integer(), nullable=False, server_default='15'), + sa.Column('long_break_interval', sa.Integer(), nullable=False, server_default='4'), + sa.Column('cycles_completed', sa.Integer(), nullable=False, server_default='0'), + sa.Column('interruptions', sa.Integer(), nullable=False, server_default='0'), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + ) + + # recurring_blocks table + if 'recurring_blocks' not in existing_tables: + op.create_table( + 'recurring_blocks', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False, index=True), + sa.Column('project_id', sa.Integer(), sa.ForeignKey('projects.id'), nullable=False, index=True), + sa.Column('task_id', sa.Integer(), sa.ForeignKey('tasks.id'), nullable=True, index=True), + sa.Column('name', sa.String(length=200), nullable=False), + sa.Column('recurrence', sa.String(length=20), nullable=False, server_default='weekly'), + sa.Column('weekdays', sa.String(length=50), nullable=True), + sa.Column('start_time_local', sa.String(length=5), nullable=False), + sa.Column('end_time_local', sa.String(length=5), nullable=False), + sa.Column('starts_on', sa.Date(), nullable=True), + sa.Column('ends_on', sa.Date(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('TRUE')), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('tags', sa.String(length=500), nullable=True), + sa.Column('billable', sa.Boolean(), nullable=False, server_default=sa.text('TRUE')), + sa.Column('last_generated_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + ) + + # rate_overrides table + if 'rate_overrides' not in existing_tables: + op.create_table( + 'rate_overrides', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('project_id', sa.Integer(), sa.ForeignKey('projects.id'), nullable=False, index=True), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True, index=True), + sa.Column('hourly_rate', sa.Numeric(9, 2), nullable=False), + sa.Column('effective_from', sa.Date(), nullable=True), + sa.Column('effective_to', sa.Date(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.UniqueConstraint('project_id', 'user_id', 'effective_from', name='ux_rate_override_unique_window'), + ) + + # saved_filters table + if 'saved_filters' not in existing_tables: + op.create_table( + 'saved_filters', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False, index=True), + sa.Column('name', sa.String(length=200), nullable=False), + sa.Column('scope', sa.String(length=50), nullable=False, server_default='global'), + sa.Column('payload', sa.JSON(), nullable=False, server_default=sa.text("'{}'")), + sa.Column('is_shared', sa.Boolean(), nullable=False, server_default=sa.text('FALSE')), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.UniqueConstraint('user_id', 'name', 'scope', name='ux_saved_filter_user_name_scope'), + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + existing_tables = set(inspector.get_table_names()) + + if 'saved_filters' in existing_tables: + op.drop_table('saved_filters') + if 'rate_overrides' in existing_tables: + op.drop_table('rate_overrides') + if 'recurring_blocks' in existing_tables: + op.drop_table('recurring_blocks') + if 'focus_sessions' in existing_tables: + op.drop_table('focus_sessions') + + if 'projects' in existing_tables: + cols = {c['name'] for c in inspector.get_columns('projects')} + with op.batch_alter_table('projects') as batch: + if 'budget_threshold_percent' in cols: + batch.drop_column('budget_threshold_percent') + if 'budget_amount' in cols: + batch.drop_column('budget_amount') + if 'estimated_hours' in cols: + batch.drop_column('estimated_hours') + + diff --git a/templates/projects/view.html b/templates/projects/view.html index b12399c..6bb478a 100644 --- a/templates/projects/view.html +++ b/templates/projects/view.html @@ -114,6 +114,18 @@
{{ "%.1f"|format(project.total_billable_hours) }}
{{ _('Billable Hours') }} + {% if project.estimated_hours %} +
+
{{ "%.1f"|format(project.estimated_hours) }}
+ {{ _('Estimated Hours') }} +
+ {% endif %} + {% if project.budget_amount %} +
+
{{ currency }} {{ "%.2f"|format(project.budget_consumed_amount) }}
+ {{ _('Budget Used') }} +
+ {% endif %} {% if project.billable and project.hourly_rate %}
{{ currency }} {{ "%.2f"|format(project.estimated_cost) }}
@@ -121,6 +133,18 @@
{% endif %} + {% if project.budget_amount %} +
+
+ {% set pct = (project.budget_consumed_amount / project.budget_amount * 100) | round(0, 'floor') %} +
{{ pct }}%
+
+
+ {{ _('Budget') }}: {{ currency }} {{ "%.2f"|format(project.budget_amount) }} + {{ _('Threshold') }}: {{ project.budget_threshold_percent }}% +
+
+ {% endif %} @@ -204,11 +228,19 @@
{{ _('Time Entries') }}
- - {{ _('View Report') }} - +
+ + {{ _('View Report') }} + + +
+ {% if entries %}
@@ -438,3 +470,32 @@ document.addEventListener('DOMContentLoaded', function() { .detail-row{flex-direction:column; align-items:flex-start; gap:4px;} } + diff --git a/templates/reports/project_report.html b/templates/reports/project_report.html index e45ca4e..5e2ccce 100644 --- a/templates/reports/project_report.html +++ b/templates/reports/project_report.html @@ -166,6 +166,9 @@
+
+ +
{% if projects_data %}
@@ -340,6 +343,23 @@ +{% block extra_js %} + +{% endblock %} {% endif %} {% endblock %} diff --git a/templates/timer/timer.html b/templates/timer/timer.html index 23dd070..d748f33 100644 --- a/templates/timer/timer.html +++ b/templates/timer/timer.html @@ -18,6 +18,9 @@ + @@ -153,6 +156,46 @@ + +