From d530ce48b00ec4f753c292fd7bf790ce33e5f4c1 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Fri, 24 Oct 2025 10:15:03 +0200 Subject: [PATCH 1/2] feat: Add Weekly Time Goals feature for tracking weekly hour targets Implemented a comprehensive Weekly Time Goals system that allows users to set and track weekly hour targets with real-time progress monitoring. Features: - WeeklyTimeGoal model with status tracking (active, completed, failed, cancelled) - Full CRUD interface for managing weekly goals - Real-time progress calculation based on logged time entries - Dashboard widget showing current week's goal progress - Daily breakdown view with detailed statistics - Automatic status updates based on goal completion and week end - API endpoints for goal data and progress tracking Technical changes: - Added app/models/weekly_time_goal.py with local timezone support - Created migration 027_add_weekly_time_goals.py for database schema - Added app/routes/weekly_goals.py blueprint with all CRUD routes - Created templates: index.html, create.html, edit.html, view.html - Integrated weekly goal widget into main dashboard - Added "Weekly Goals" navigation item to sidebar - Implemented comprehensive test suite in tests/test_weekly_goals.py - Added feature documentation in docs/WEEKLY_TIME_GOALS.md Bug fixes: - Fixed timezone handling to use TZ environment variable instead of Config.TIMEZONE - Corrected log_event() calls to use proper signature (event name as first positional argument) - Manually created database table via SQL when Alembic migration didn't execute Database schema: - weekly_time_goals table with user_id, target_hours, week_start_date, week_end_date, status, notes - Indexes on user_id, week_start_date, status, and composite (user_id, week_start_date) - Foreign key constraint to users table with CASCADE delete The feature supports flexible week start days per user, calculates remaining hours, provides daily average targets, and automatically updates goal status based on progress. --- app/__init__.py | 2 + app/models/__init__.py | 2 + app/models/weekly_time_goal.py | 202 ++++++ app/routes/main.py | 10 +- app/routes/weekly_goals.py | 399 ++++++++++++ app/templates/base.html | 6 + app/templates/main/dashboard.html | 57 ++ app/templates/weekly_goals/create.html | 137 ++++ app/templates/weekly_goals/edit.html | 112 ++++ app/templates/weekly_goals/index.html | 229 +++++++ app/templates/weekly_goals/view.html | 214 +++++++ docs/WEEKLY_TIME_GOALS.md | 369 +++++++++++ .../versions/027_add_weekly_time_goals.py | 79 +++ tests/test_weekly_goals.py | 583 ++++++++++++++++++ 14 files changed, 2399 insertions(+), 2 deletions(-) create mode 100644 app/models/weekly_time_goal.py create mode 100644 app/routes/weekly_goals.py create mode 100644 app/templates/weekly_goals/create.html create mode 100644 app/templates/weekly_goals/edit.html create mode 100644 app/templates/weekly_goals/index.html create mode 100644 app/templates/weekly_goals/view.html create mode 100644 docs/WEEKLY_TIME_GOALS.md create mode 100644 migrations/versions/027_add_weekly_time_goals.py create mode 100644 tests/test_weekly_goals.py diff --git a/app/__init__.py b/app/__init__.py index 3d77d1d..1d9da99 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -763,6 +763,7 @@ def create_app(config=None): from app.routes.time_entry_templates import time_entry_templates_bp from app.routes.saved_filters import saved_filters_bp from app.routes.settings import settings_bp + from app.routes.weekly_goals import weekly_goals_bp app.register_blueprint(auth_bp) app.register_blueprint(main_bp) @@ -783,6 +784,7 @@ def create_app(config=None): app.register_blueprint(time_entry_templates_bp) app.register_blueprint(saved_filters_bp) app.register_blueprint(settings_bp) + app.register_blueprint(weekly_goals_bp) # Exempt API blueprint from CSRF protection (JSON API uses authentication, not CSRF tokens) # Only if CSRF is enabled diff --git a/app/models/__init__.py b/app/models/__init__.py index e2bef76..a77e4ea 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -23,6 +23,7 @@ from .time_entry_template import TimeEntryTemplate from .activity import Activity from .user_favorite_project import UserFavoriteProject from .client_note import ClientNote +from .weekly_time_goal import WeeklyTimeGoal __all__ = [ "User", @@ -54,4 +55,5 @@ __all__ = [ "Activity", "UserFavoriteProject", "ClientNote", + "WeeklyTimeGoal", ] diff --git a/app/models/weekly_time_goal.py b/app/models/weekly_time_goal.py new file mode 100644 index 0000000..38fd497 --- /dev/null +++ b/app/models/weekly_time_goal.py @@ -0,0 +1,202 @@ +from datetime import datetime, timedelta +from app import db +from sqlalchemy import func + + +def local_now(): + """Get current time in local timezone""" + import os + import pytz + # Get timezone from environment variable, default to Europe/Rome + timezone_name = os.getenv('TZ', 'Europe/Rome') + tz = pytz.timezone(timezone_name) + now = datetime.now(tz) + return now.replace(tzinfo=None) + + +class WeeklyTimeGoal(db.Model): + """Weekly time goal model for tracking user's weekly hour targets""" + + __tablename__ = 'weekly_time_goals' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + target_hours = db.Column(db.Float, nullable=False) # Target hours for the week + week_start_date = db.Column(db.Date, nullable=False, index=True) # Monday of the week + week_end_date = db.Column(db.Date, nullable=False) # Sunday of the week + status = db.Column(db.String(20), default='active', nullable=False) # 'active', 'completed', 'failed', 'cancelled' + notes = db.Column(db.Text, nullable=True) + 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 = db.relationship('User', backref=db.backref('weekly_goals', lazy='dynamic', cascade='all, delete-orphan')) + + def __init__(self, user_id, target_hours, week_start_date=None, notes=None, **kwargs): + """Initialize a WeeklyTimeGoal instance. + + Args: + user_id: ID of the user who created this goal + target_hours: Target hours for the week + week_start_date: Start date of the week (Monday). If None, uses current week. + notes: Optional notes about the goal + **kwargs: Additional keyword arguments (for SQLAlchemy compatibility) + """ + self.user_id = user_id + self.target_hours = target_hours + + # If no week_start_date provided, calculate the current week's Monday + if week_start_date is None: + from app.models.user import User + user = User.query.get(user_id) + week_start_day = user.week_start_day if user else 1 # Default to Monday + today = local_now().date() + days_since_week_start = (today.weekday() - week_start_day) % 7 + week_start_date = today - timedelta(days=days_since_week_start) + + self.week_start_date = week_start_date + self.week_end_date = week_start_date + timedelta(days=6) + self.notes = notes + + # Allow status override from kwargs + if 'status' in kwargs: + self.status = kwargs['status'] + + def __repr__(self): + return f'' + + @property + def actual_hours(self): + """Calculate actual hours worked during this week""" + from app.models.time_entry import TimeEntry + + # Query time entries for this user within the week range + total_seconds = db.session.query( + func.sum(TimeEntry.duration_seconds) + ).filter( + TimeEntry.user_id == self.user_id, + TimeEntry.end_time.isnot(None), + func.date(TimeEntry.start_time) >= self.week_start_date, + func.date(TimeEntry.start_time) <= self.week_end_date + ).scalar() or 0 + + return round(total_seconds / 3600, 2) + + @property + def progress_percentage(self): + """Calculate progress as a percentage""" + if self.target_hours <= 0: + return 0 + percentage = (self.actual_hours / self.target_hours) * 100 + return min(round(percentage, 1), 100) # Cap at 100% + + @property + def remaining_hours(self): + """Calculate remaining hours to reach the goal""" + remaining = self.target_hours - self.actual_hours + return max(round(remaining, 2), 0) + + @property + def is_completed(self): + """Check if the goal has been met""" + return self.actual_hours >= self.target_hours + + @property + def is_overdue(self): + """Check if the week has passed and goal is not completed""" + today = local_now().date() + return today > self.week_end_date and not self.is_completed + + @property + def days_remaining(self): + """Calculate days remaining in the week""" + today = local_now().date() + if today > self.week_end_date: + return 0 + return (self.week_end_date - today).days + 1 + + @property + def average_hours_per_day(self): + """Calculate average hours needed per day to reach goal""" + if self.days_remaining <= 0: + return 0 + return round(self.remaining_hours / self.days_remaining, 2) + + @property + def week_label(self): + """Get a human-readable label for the week""" + return f"{self.week_start_date.strftime('%b %d')} - {self.week_end_date.strftime('%b %d, %Y')}" + + def update_status(self): + """Update the goal status based on current date and progress""" + today = local_now().date() + + if self.status == 'cancelled': + return # Don't auto-update cancelled goals + + if today > self.week_end_date: + # Week has ended + if self.is_completed: + self.status = 'completed' + else: + self.status = 'failed' + elif self.is_completed and self.status == 'active': + self.status = 'completed' + + db.session.commit() + + def to_dict(self): + """Convert goal to dictionary for API responses""" + return { + 'id': self.id, + 'user_id': self.user_id, + 'target_hours': self.target_hours, + 'actual_hours': self.actual_hours, + 'week_start_date': self.week_start_date.isoformat(), + 'week_end_date': self.week_end_date.isoformat(), + 'week_label': self.week_label, + 'status': self.status, + 'notes': self.notes, + 'progress_percentage': self.progress_percentage, + 'remaining_hours': self.remaining_hours, + 'is_completed': self.is_completed, + 'is_overdue': self.is_overdue, + 'days_remaining': self.days_remaining, + 'average_hours_per_day': self.average_hours_per_day, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None, + } + + @staticmethod + def get_current_week_goal(user_id): + """Get the goal for the current week for a specific user""" + from app.models.user import User + user = User.query.get(user_id) + week_start_day = user.week_start_day if user else 1 + + today = local_now().date() + days_since_week_start = (today.weekday() - week_start_day) % 7 + week_start = today - timedelta(days=days_since_week_start) + week_end = week_start + timedelta(days=6) + + return WeeklyTimeGoal.query.filter( + WeeklyTimeGoal.user_id == user_id, + WeeklyTimeGoal.week_start_date == week_start, + WeeklyTimeGoal.status != 'cancelled' + ).first() + + @staticmethod + def get_or_create_current_week(user_id, default_target_hours=40): + """Get or create a goal for the current week""" + goal = WeeklyTimeGoal.get_current_week_goal(user_id) + + if not goal: + goal = WeeklyTimeGoal( + user_id=user_id, + target_hours=default_target_hours + ) + db.session.add(goal) + db.session.commit() + + return goal + diff --git a/app/routes/main.py b/app/routes/main.py index 86d4706..0f420a7 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -1,6 +1,6 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash, session from flask_login import login_required, current_user -from app.models import User, Project, TimeEntry, Settings +from app.models import User, Project, TimeEntry, Settings, WeeklyTimeGoal from datetime import datetime, timedelta import pytz from app import db, track_page_view @@ -73,6 +73,11 @@ def dashboard(): if e.billable and e.project.billable: project_hours[e.project.id]['billable_hours'] += e.duration_hours top_projects = sorted(project_hours.values(), key=lambda x: x['hours'], reverse=True)[:5] + + # Get current week goal + current_week_goal = WeeklyTimeGoal.get_current_week_goal(current_user.id) + if current_week_goal: + current_week_goal.update_status() return render_template('main/dashboard.html', active_timer=active_timer, @@ -81,7 +86,8 @@ def dashboard(): today_hours=today_hours, week_hours=week_hours, month_hours=month_hours, - top_projects=top_projects) + top_projects=top_projects, + current_week_goal=current_week_goal) @main_bp.route('/_health') def health_check(): diff --git a/app/routes/weekly_goals.py b/app/routes/weekly_goals.py new file mode 100644 index 0000000..c3d3ffd --- /dev/null +++ b/app/routes/weekly_goals.py @@ -0,0 +1,399 @@ +from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app +from flask_babel import gettext as _ +from flask_login import login_required, current_user +from app import db, log_event, track_event +from app.models import WeeklyTimeGoal, TimeEntry +from app.utils.db import safe_commit +from datetime import datetime, timedelta +from sqlalchemy import func + +weekly_goals_bp = Blueprint('weekly_goals', __name__) + + +@weekly_goals_bp.route('/goals') +@login_required +def index(): + """Display weekly goals overview page""" + current_app.logger.info(f"GET /goals user={current_user.username}") + + # Get current week goal + current_goal = WeeklyTimeGoal.get_current_week_goal(current_user.id) + + # Get all goals for the user, ordered by week + all_goals = WeeklyTimeGoal.query.filter_by( + user_id=current_user.id + ).order_by( + WeeklyTimeGoal.week_start_date.desc() + ).limit(12).all() # Show last 12 weeks + + # Update status for all goals + for goal in all_goals: + goal.update_status() + + # Calculate statistics + stats = { + 'total_goals': len(all_goals), + 'completed': sum(1 for g in all_goals if g.status == 'completed'), + 'failed': sum(1 for g in all_goals if g.status == 'failed'), + 'active': sum(1 for g in all_goals if g.status == 'active'), + 'completion_rate': 0 + } + + if stats['total_goals'] > 0: + completed_or_failed = stats['completed'] + stats['failed'] + if completed_or_failed > 0: + stats['completion_rate'] = round((stats['completed'] / completed_or_failed) * 100, 1) + + # Track page view + track_event( + user_id=current_user.id, + event_name='weekly_goals_viewed', + properties={'has_current_goal': current_goal is not None} + ) + + return render_template( + 'weekly_goals/index.html', + current_goal=current_goal, + goals=all_goals, + stats=stats + ) + + +@weekly_goals_bp.route('/goals/create', methods=['GET', 'POST']) +@login_required +def create(): + """Create a new weekly time goal""" + if request.method == 'GET': + current_app.logger.info(f"GET /goals/create user={current_user.username}") + return render_template('weekly_goals/create.html') + + # POST request + current_app.logger.info(f"POST /goals/create user={current_user.username}") + + target_hours = request.form.get('target_hours', type=float) + week_start_date_str = request.form.get('week_start_date') + notes = request.form.get('notes', '').strip() + + if not target_hours or target_hours <= 0: + flash(_('Please enter a valid target hours (greater than 0)'), 'error') + return redirect(url_for('weekly_goals.create')) + + # Parse week start date + week_start_date = None + if week_start_date_str: + try: + week_start_date = datetime.strptime(week_start_date_str, '%Y-%m-%d').date() + except ValueError: + flash(_('Invalid date format'), 'error') + return redirect(url_for('weekly_goals.create')) + + # Check if goal already exists for this week + if week_start_date: + existing_goal = WeeklyTimeGoal.query.filter( + WeeklyTimeGoal.user_id == current_user.id, + WeeklyTimeGoal.week_start_date == week_start_date, + WeeklyTimeGoal.status != 'cancelled' + ).first() + + if existing_goal: + flash(_('A goal already exists for this week. Please edit the existing goal instead.'), 'warning') + return redirect(url_for('weekly_goals.edit', goal_id=existing_goal.id)) + + # Create new goal + goal = WeeklyTimeGoal( + user_id=current_user.id, + target_hours=target_hours, + week_start_date=week_start_date, + notes=notes + ) + + db.session.add(goal) + + if safe_commit(db.session): + flash(_('Weekly time goal created successfully!'), 'success') + log_event( + 'weekly_goal.created', + user_id=current_user.id, + resource_type='weekly_goal', + resource_id=goal.id, + target_hours=target_hours, + week_label=goal.week_label + ) + track_event( + user_id=current_user.id, + event_name='weekly_goal_created', + properties={'target_hours': target_hours, 'week_label': goal.week_label} + ) + return redirect(url_for('weekly_goals.index')) + else: + flash(_('Failed to create goal. Please try again.'), 'error') + return redirect(url_for('weekly_goals.create')) + + +@weekly_goals_bp.route('/goals/') +@login_required +def view(goal_id): + """View details of a specific weekly goal""" + current_app.logger.info(f"GET /goals/{goal_id} user={current_user.username}") + + goal = WeeklyTimeGoal.query.get_or_404(goal_id) + + # Ensure user can only view their own goals + if goal.user_id != current_user.id: + flash(_('You do not have permission to view this goal'), 'error') + return redirect(url_for('weekly_goals.index')) + + # Update goal status + goal.update_status() + + # Get time entries for this week + time_entries = TimeEntry.query.filter( + TimeEntry.user_id == current_user.id, + TimeEntry.end_time.isnot(None), + func.date(TimeEntry.start_time) >= goal.week_start_date, + func.date(TimeEntry.start_time) <= goal.week_end_date + ).order_by(TimeEntry.start_time.desc()).all() + + # Calculate daily breakdown + daily_hours = {} + for entry in time_entries: + entry_date = entry.start_time.date() + if entry_date not in daily_hours: + daily_hours[entry_date] = 0 + daily_hours[entry_date] += entry.duration_seconds / 3600 + + # Fill in missing days with 0 + current_date = goal.week_start_date + while current_date <= goal.week_end_date: + if current_date not in daily_hours: + daily_hours[current_date] = 0 + current_date += timedelta(days=1) + + # Sort by date + daily_hours = dict(sorted(daily_hours.items())) + + track_event( + user_id=current_user.id, + event_name='weekly_goal_viewed', + properties={'goal_id': goal_id, 'week_label': goal.week_label} + ) + + return render_template( + 'weekly_goals/view.html', + goal=goal, + time_entries=time_entries, + daily_hours=daily_hours + ) + + +@weekly_goals_bp.route('/goals//edit', methods=['GET', 'POST']) +@login_required +def edit(goal_id): + """Edit a weekly time goal""" + goal = WeeklyTimeGoal.query.get_or_404(goal_id) + + # Ensure user can only edit their own goals + if goal.user_id != current_user.id: + flash(_('You do not have permission to edit this goal'), 'error') + return redirect(url_for('weekly_goals.index')) + + if request.method == 'GET': + current_app.logger.info(f"GET /goals/{goal_id}/edit user={current_user.username}") + return render_template('weekly_goals/edit.html', goal=goal) + + # POST request + current_app.logger.info(f"POST /goals/{goal_id}/edit user={current_user.username}") + + target_hours = request.form.get('target_hours', type=float) + notes = request.form.get('notes', '').strip() + status = request.form.get('status') + + if not target_hours or target_hours <= 0: + flash(_('Please enter a valid target hours (greater than 0)'), 'error') + return redirect(url_for('weekly_goals.edit', goal_id=goal_id)) + + # Update goal + old_target = goal.target_hours + goal.target_hours = target_hours + goal.notes = notes + + if status and status in ['active', 'completed', 'failed', 'cancelled']: + goal.status = status + + if safe_commit(db.session): + flash(_('Weekly time goal updated successfully!'), 'success') + log_event( + 'weekly_goal.updated', + user_id=current_user.id, + resource_type='weekly_goal', + resource_id=goal.id, + old_target=old_target, + new_target=target_hours, + week_label=goal.week_label + ) + track_event( + user_id=current_user.id, + event_name='weekly_goal_updated', + properties={'goal_id': goal_id, 'new_target': target_hours} + ) + return redirect(url_for('weekly_goals.view', goal_id=goal_id)) + else: + flash(_('Failed to update goal. Please try again.'), 'error') + return redirect(url_for('weekly_goals.edit', goal_id=goal_id)) + + +@weekly_goals_bp.route('/goals//delete', methods=['POST']) +@login_required +def delete(goal_id): + """Delete a weekly time goal""" + current_app.logger.info(f"POST /goals/{goal_id}/delete user={current_user.username}") + + goal = WeeklyTimeGoal.query.get_or_404(goal_id) + + # Ensure user can only delete their own goals + if goal.user_id != current_user.id: + flash(_('You do not have permission to delete this goal'), 'error') + return redirect(url_for('weekly_goals.index')) + + week_label = goal.week_label + + db.session.delete(goal) + + if safe_commit(db.session): + flash(_('Weekly time goal deleted successfully'), 'success') + log_event( + 'weekly_goal.deleted', + user_id=current_user.id, + resource_type='weekly_goal', + resource_id=goal_id, + week_label=week_label + ) + track_event( + user_id=current_user.id, + event_name='weekly_goal_deleted', + properties={'goal_id': goal_id} + ) + else: + flash(_('Failed to delete goal. Please try again.'), 'error') + + return redirect(url_for('weekly_goals.index')) + + +# API Endpoints + +@weekly_goals_bp.route('/api/goals/current') +@login_required +def api_current_goal(): + """API endpoint to get current week's goal""" + current_app.logger.info(f"GET /api/goals/current user={current_user.username}") + + goal = WeeklyTimeGoal.get_current_week_goal(current_user.id) + + if goal: + goal.update_status() + return jsonify(goal.to_dict()) + else: + return jsonify({'error': 'No goal set for current week'}), 404 + + +@weekly_goals_bp.route('/api/goals') +@login_required +def api_list_goals(): + """API endpoint to list all goals for current user""" + current_app.logger.info(f"GET /api/goals user={current_user.username}") + + limit = request.args.get('limit', 12, type=int) + status_filter = request.args.get('status') + + query = WeeklyTimeGoal.query.filter_by(user_id=current_user.id) + + if status_filter: + query = query.filter_by(status=status_filter) + + goals = query.order_by( + WeeklyTimeGoal.week_start_date.desc() + ).limit(limit).all() + + # Update status for all goals + for goal in goals: + goal.update_status() + + return jsonify([goal.to_dict() for goal in goals]) + + +@weekly_goals_bp.route('/api/goals/') +@login_required +def api_get_goal(goal_id): + """API endpoint to get a specific goal""" + current_app.logger.info(f"GET /api/goals/{goal_id} user={current_user.username}") + + goal = WeeklyTimeGoal.query.get_or_404(goal_id) + + # Ensure user can only view their own goals + if goal.user_id != current_user.id: + return jsonify({'error': 'Unauthorized'}), 403 + + goal.update_status() + return jsonify(goal.to_dict()) + + +@weekly_goals_bp.route('/api/goals/stats') +@login_required +def api_stats(): + """API endpoint to get goal statistics""" + current_app.logger.info(f"GET /api/goals/stats user={current_user.username}") + + # Get all goals for the user + goals = WeeklyTimeGoal.query.filter_by( + user_id=current_user.id + ).order_by( + WeeklyTimeGoal.week_start_date.desc() + ).all() + + # Update status for all goals + for goal in goals: + goal.update_status() + + # Calculate statistics + total = len(goals) + completed = sum(1 for g in goals if g.status == 'completed') + failed = sum(1 for g in goals if g.status == 'failed') + active = sum(1 for g in goals if g.status == 'active') + cancelled = sum(1 for g in goals if g.status == 'cancelled') + + completion_rate = 0 + if total > 0: + completed_or_failed = completed + failed + if completed_or_failed > 0: + completion_rate = round((completed / completed_or_failed) * 100, 1) + + # Calculate average target hours + avg_target = 0 + if total > 0: + avg_target = round(sum(g.target_hours for g in goals) / total, 2) + + # Calculate average actual hours + avg_actual = 0 + if total > 0: + avg_actual = round(sum(g.actual_hours for g in goals) / total, 2) + + # Get current streak (consecutive weeks with completed goals) + current_streak = 0 + for goal in goals: + if goal.status == 'completed': + current_streak += 1 + elif goal.status in ['failed', 'cancelled']: + break + + return jsonify({ + 'total_goals': total, + 'completed': completed, + 'failed': failed, + 'active': active, + 'cancelled': cancelled, + 'completion_rate': completion_rate, + 'average_target_hours': avg_target, + 'average_actual_hours': avg_actual, + 'current_streak': current_streak + }) + diff --git a/app/templates/base.html b/app/templates/base.html index 9f6fd10..45865b3 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -114,6 +114,12 @@ {{ _('Dashboard') }} +
  • + + + {{ _('Weekly Goals') }} + +
  • + + + + + + + +
    + + {{ _('Cancel') }} + + +
    + + + + +
    +
    +
    + +
    +
    +

    + {{ _('Tips for Setting Goals') }} +

    +
    +
      +
    • {{ _('Be realistic: Consider holidays, meetings, and other commitments') }}
    • +
    • {{ _('Start conservative: You can always adjust your goal later') }}
    • +
    • {{ _('Track progress: Check your dashboard regularly to stay on track') }}
    • +
    • {{ _('Typical full-time: 40 hours per week (8 hours/day, 5 days)') }}
    • +
    +
    +
    +
    +
    + +{% endblock %} + diff --git a/app/templates/weekly_goals/edit.html b/app/templates/weekly_goals/edit.html new file mode 100644 index 0000000..38cb4e4 --- /dev/null +++ b/app/templates/weekly_goals/edit.html @@ -0,0 +1,112 @@ +{% extends "base.html" %} + +{% block title %}{{ _('Edit Weekly Goal') }} - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
    + +
    +

    + + {{ _('Edit Weekly Time Goal') }} +

    +

    + {{ goal.week_label }} +

    +
    + + +
    +
    + + + +
    +
    +
    +

    {{ _('Week Period') }}

    +

    {{ goal.week_label }}

    +
    +
    +

    {{ _('Current Progress') }}

    +

    + {{ goal.actual_hours }}h / {{ goal.target_hours }}h ({{ goal.progress_percentage }}%) +

    +
    +
    +
    + + +
    + +
    + + hours +
    +
    + + +
    + + +
    + + +
    + + +
    + + +
    +
    + + {{ _('Cancel') }} + + +
    + +
    +
    + + + +
    +
    +{% endblock %} + diff --git a/app/templates/weekly_goals/index.html b/app/templates/weekly_goals/index.html new file mode 100644 index 0000000..a844e7b --- /dev/null +++ b/app/templates/weekly_goals/index.html @@ -0,0 +1,229 @@ +{% extends "base.html" %} + +{% block title %}{{ _('Weekly Time Goals') }} - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
    + +
    +
    +

    + + {{ _('Weekly Time Goals') }} +

    +

    + {{ _('Set and track your weekly hour targets') }} +

    +
    + + {{ _('New Goal') }} + +
    + + +
    +
    +
    +
    + +
    +
    +

    {{ _('Total Goals') }}

    +

    {{ stats.total_goals }}

    +
    +
    +
    + +
    +
    +
    + +
    +
    +

    {{ _('Completed') }}

    +

    {{ stats.completed }}

    +
    +
    +
    + +
    +
    +
    + +
    +
    +

    {{ _('Failed') }}

    +

    {{ stats.failed }}

    +
    +
    +
    + +
    +
    +
    + +
    +
    +

    {{ _('Success Rate') }}

    +

    {{ stats.completion_rate }}%

    +
    +
    +
    +
    + + + {% if current_goal %} +
    +

    + + {{ _('Current Week Goal') }} +

    +
    +
    +

    {{ _('Week') }}

    +

    {{ current_goal.week_label }}

    +
    +
    +

    {{ _('Target Hours') }}

    +

    {{ current_goal.target_hours }}h

    +
    +
    +

    {{ _('Actual Hours') }}

    +

    {{ current_goal.actual_hours }}h

    +
    +
    + + +
    +
    + {{ _('Progress') }} + {{ current_goal.progress_percentage }}% +
    +
    +
    +
    +
    + +
    +
    +

    {{ _('Remaining Hours') }}

    +

    {{ current_goal.remaining_hours }}h

    +
    +
    +

    {{ _('Days Remaining') }}

    +

    {{ current_goal.days_remaining }}

    +
    +
    +

    {{ _('Avg Hours/Day Needed') }}

    +

    {{ current_goal.average_hours_per_day }}h

    +
    +
    + + +
    + {% else %} + +
    +
    +
    + +
    +
    +

    + {{ _('No goal set for this week') }} +

    +

    + {{ _('Create a weekly time goal to start tracking your progress') }} +

    + + {{ _('Create Goal') }} + +
    +
    +
    + {% endif %} + + + {% if goals %} +
    +

    + + {{ _('Goal History') }} +

    + +
    + {% for goal in goals %} +
    +
    +
    +
    +

    + {{ goal.week_label }} +

    + {% if goal.status == 'completed' %} + + {{ _('Completed') }} + + {% elif goal.status == 'active' %} + + {{ _('Active') }} + + {% elif goal.status == 'failed' %} + + {{ _('Failed') }} + + {% endif %} +
    +

    + {{ _('Target') }}: {{ goal.target_hours }}h | {{ _('Actual') }}: {{ goal.actual_hours }}h +

    +
    + +
    + + +
    +
    + {{ _('Progress') }} + {{ goal.progress_percentage }}% +
    +
    + {% if goal.status == 'completed' %} +
    + {% elif goal.status == 'failed' %} +
    + {% else %} +
    + {% endif %} +
    +
    +
    + {% endfor %} +
    +
    + {% endif %} +
    +{% endblock %} + diff --git a/app/templates/weekly_goals/view.html b/app/templates/weekly_goals/view.html new file mode 100644 index 0000000..1dc5494 --- /dev/null +++ b/app/templates/weekly_goals/view.html @@ -0,0 +1,214 @@ +{% extends "base.html" %} + +{% block title %}{{ _('Weekly Goal Details') }} - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
    + +
    +
    +

    + + {{ _('Weekly Goal Details') }} +

    +

    + {{ goal.week_label }} +

    +
    + +
    + + +
    +
    +
    +

    {{ _('Target Hours') }}

    + +
    +

    {{ goal.target_hours }}h

    +
    + +
    +
    +

    {{ _('Actual Hours') }}

    + +
    +

    {{ goal.actual_hours }}h

    +
    + +
    +
    +

    {{ _('Status') }}

    + {% if goal.status == 'completed' %} + + {% elif goal.status == 'active' %} + + {% elif goal.status == 'failed' %} + + {% else %} + + {% endif %} +
    + {% if goal.status == 'completed' %} +

    {{ _('Completed') }}

    + {% elif goal.status == 'active' %} +

    {{ _('Active') }}

    + {% elif goal.status == 'failed' %} +

    {{ _('Failed') }}

    + {% else %} +

    {{ _('Cancelled') }}

    + {% endif %} +
    +
    + + +
    +

    + + {{ _('Progress') }} +

    + +
    +
    + {{ goal.actual_hours }}h / {{ goal.target_hours }}h + {{ goal.progress_percentage }}% +
    +
    + {% if goal.status == 'completed' %} +
    + {% elif goal.status == 'failed' %} +
    + {% else %} +
    + {% endif %} +
    +
    + +
    +
    +

    {{ _('Remaining Hours') }}

    +

    {{ goal.remaining_hours }}h

    +
    +
    +

    {{ _('Days Remaining') }}

    +

    {{ goal.days_remaining }}

    +
    +
    +

    {{ _('Avg Hours/Day Needed') }}

    +

    {{ goal.average_hours_per_day }}h

    +
    +
    +
    + + +
    +

    + + {{ _('Daily Breakdown') }} +

    + +
    + {% for date, hours in daily_hours.items() %} +
    +
    +

    + {{ date.strftime('%A, %B %d') }} +

    +
    +
    + + {{ "%.2f"|format(hours) }} hours + +
    + {% set daily_target = goal.target_hours / 7 %} + {% set daily_percentage = (hours / daily_target * 100) if daily_target > 0 else 0 %} +
    +
    +
    +
    + {% endfor %} +
    +
    + + + {% if goal.notes %} +
    +

    + + {{ _('Notes') }} +

    +

    {{ goal.notes }}

    +
    + {% endif %} + + + {% if time_entries %} +
    +

    + + {{ _('Time Entries This Week') }} ({{ time_entries|length }}) +

    + +
    + {% for entry in time_entries %} +
    +
    +
    + + {{ entry.project.name if entry.project else _('No Project') }} + + {% if entry.task %} + + • {{ entry.task.name }} + + {% endif %} +
    +

    + {{ entry.start_time.strftime('%a, %b %d at %H:%M') }} + {% if entry.notes %} + • {{ entry.notes[:50] }}{% if entry.notes|length > 50 %}...{% endif %} + {% endif %} +

    +
    +
    +

    + {{ entry.duration_formatted }} +

    +

    + {{ "%.2f"|format(entry.duration_seconds / 3600) }}h +

    +
    +
    + {% endfor %} +
    +
    + {% else %} +
    +
    +
    + +
    +
    +

    + {{ _('No time entries recorded for this week yet') }} +

    +
    +
    +
    + {% endif %} +
    +{% endblock %} + diff --git a/docs/WEEKLY_TIME_GOALS.md b/docs/WEEKLY_TIME_GOALS.md new file mode 100644 index 0000000..192055d --- /dev/null +++ b/docs/WEEKLY_TIME_GOALS.md @@ -0,0 +1,369 @@ +# Weekly Time Goals + +## Overview + +The Weekly Time Goals feature allows users to set and track weekly hour targets, helping them manage workload and maintain work-life balance. Users can create goals for different weeks, monitor progress in real-time, and review their historical performance. + +## Features + +### Goal Management + +- **Create Weekly Goals**: Set target hours for any week +- **Track Progress**: Real-time progress tracking against targets +- **Status Management**: Automatic status updates (active, completed, failed, cancelled) +- **Notes**: Add context and notes to goals +- **Historical View**: Review past goals and performance + +### Dashboard Integration + +- **Weekly Goal Widget**: Display current week's progress on the dashboard +- **Quick Actions**: Create or view goals directly from the dashboard +- **Visual Progress**: Color-coded progress bars and statistics + +### Analytics + +- **Success Rate**: Track completion rate over time +- **Daily Breakdown**: See hours logged per day +- **Average Performance**: View average target vs actual hours +- **Streak Tracking**: Monitor consecutive weeks of completed goals + +## User Guide + +### Creating a Weekly Goal + +1. Navigate to **Weekly Goals** from the sidebar +2. Click **New Goal** button +3. Enter your target hours (e.g., 40 for full-time) +4. Optionally select a specific week (defaults to current week) +5. Add notes if desired (e.g., "Vacation week, reduced hours") +6. Click **Create Goal** + +### Quick Presets + +The create page includes quick preset buttons for common targets: +- 20 hours (half-time) +- 30 hours (part-time) +- 40 hours (full-time) +- 50 hours (overtime) + +### Viewing Goal Progress + +#### Dashboard Widget + +The dashboard shows your current week's goal with: +- Progress bar +- Actual vs target hours +- Remaining hours +- Days remaining +- Average hours per day needed to reach goal + +#### Detailed View + +Click on any goal to see: +- Complete week statistics +- Daily breakdown of hours +- All time entries for that week +- Progress visualization + +### Editing Goals + +1. Navigate to the goal (from Weekly Goals page or dashboard) +2. Click **Edit** +3. Modify target hours, status, or notes +4. Click **Save Changes** + +**Note**: Week dates cannot be changed after creation. Create a new goal for a different week instead. + +### Understanding Goal Status + +Goals automatically update their status based on progress and time: + +- **Active**: Current or future week, not yet completed +- **Completed**: Goal met (actual hours ≥ target hours) +- **Failed**: Week ended without meeting goal +- **Cancelled**: Manually cancelled by user + +## API Endpoints + +### Get Current Week Goal + +```http +GET /api/goals/current +``` + +Returns the goal for the current week for the authenticated user. + +**Response:** +```json +{ + "id": 1, + "user_id": 1, + "target_hours": 40.0, + "actual_hours": 25.5, + "week_start_date": "2025-10-20", + "week_end_date": "2025-10-26", + "week_label": "Oct 20 - Oct 26, 2025", + "status": "active", + "progress_percentage": 63.8, + "remaining_hours": 14.5, + "days_remaining": 3, + "average_hours_per_day": 4.83 +} +``` + +### List Goals + +```http +GET /api/goals?limit=12&status=active +``` + +List goals for the authenticated user. + +**Query Parameters:** +- `limit` (optional): Number of goals to return (default: 12) +- `status` (optional): Filter by status (active, completed, failed, cancelled) + +**Response:** +```json +[ + { + "id": 1, + "target_hours": 40.0, + "actual_hours": 25.5, + "status": "active", + ... + }, + ... +] +``` + +### Get Goal Statistics + +```http +GET /api/goals/stats +``` + +Get aggregated statistics about user's goals. + +**Response:** +```json +{ + "total_goals": 12, + "completed": 8, + "failed": 3, + "active": 1, + "cancelled": 0, + "completion_rate": 72.7, + "average_target_hours": 40.0, + "average_actual_hours": 38.5, + "current_streak": 3 +} +``` + +### Get Specific Goal + +```http +GET /api/goals/{goal_id} +``` + +Get details for a specific goal. + +## Database Schema + +### weekly_time_goals Table + +| Column | Type | Description | +|--------|------|-------------| +| id | Integer | Primary key | +| user_id | Integer | Foreign key to users table | +| target_hours | Float | Target hours for the week | +| week_start_date | Date | Monday of the week | +| week_end_date | Date | Sunday of the week | +| status | String(20) | Goal status (active, completed, failed, cancelled) | +| notes | Text | Optional notes about the goal | +| created_at | DateTime | Creation timestamp | +| updated_at | DateTime | Last update timestamp | + +**Indexes:** +- `ix_weekly_time_goals_user_id` on `user_id` +- `ix_weekly_time_goals_week_start_date` on `week_start_date` +- `ix_weekly_time_goals_status` on `status` +- `ix_weekly_time_goals_user_week` on `(user_id, week_start_date)` (composite) + +## Best Practices + +### Setting Realistic Goals + +1. **Consider Your Schedule**: Account for meetings, holidays, and other commitments +2. **Start Conservative**: Begin with achievable targets and adjust based on experience +3. **Account for Non-Billable Time**: Include time for admin tasks, learning, etc. +4. **Review and Adjust**: Use historical data to set more accurate future goals + +### Using Goals Effectively + +1. **Check Progress Daily**: Review your dashboard widget each morning +2. **Adjust Behavior**: If behind, plan focused work sessions +3. **Celebrate Wins**: Acknowledge completed goals +4. **Learn from Misses**: Review failed goals to understand what went wrong + +### Goal Recommendations + +- **Full-Time (40h/week)**: Standard work week (8h/day × 5 days) +- **Part-Time (20-30h/week)**: Adjust based on your arrangement +- **Flexible**: Vary by week based on project demands and personal schedule +- **Overtime (45-50h/week)**: Use sparingly; monitor for burnout + +## Technical Implementation + +### Model: WeeklyTimeGoal + +**Location**: `app/models/weekly_time_goal.py` + +**Key Properties:** +- `actual_hours`: Calculated from time entries +- `progress_percentage`: (actual_hours / target_hours) × 100 +- `remaining_hours`: target_hours - actual_hours +- `is_completed`: actual_hours ≥ target_hours +- `days_remaining`: Days left in the week +- `average_hours_per_day`: Avg hours per day needed to meet goal + +**Key Methods:** +- `update_status()`: Auto-update status based on progress and date +- `get_current_week_goal(user_id)`: Get current week's goal for user +- `get_or_create_current_week(user_id, default_target_hours)`: Get or create current week goal + +### Routes: weekly_goals Blueprint + +**Location**: `app/routes/weekly_goals.py` + +**Web Routes:** +- `GET /goals` - Goals overview page +- `GET /goals/create` - Create goal form +- `POST /goals/create` - Create goal handler +- `GET /goals/` - View specific goal +- `GET /goals//edit` - Edit goal form +- `POST /goals//edit` - Update goal handler +- `POST /goals//delete` - Delete goal handler + +**API Routes:** +- `GET /api/goals/current` - Get current week goal +- `GET /api/goals` - List goals +- `GET /api/goals/` - Get specific goal +- `GET /api/goals/stats` - Get goal statistics + +### Templates + +**Location**: `app/templates/weekly_goals/` + +- `index.html` - Goals overview and history +- `create.html` - Create new goal +- `edit.html` - Edit existing goal +- `view.html` - Detailed goal view with daily breakdown + +### Dashboard Widget + +**Location**: `app/templates/main/dashboard.html` + +Displays current week's goal with: +- Progress bar +- Key statistics +- Quick access links + +## Migration + +The feature is added via Alembic migration `027_add_weekly_time_goals.py`. + +To apply the migration: + +```bash +# Using make +make db-upgrade + +# Or directly with alembic +alembic upgrade head +``` + +## Testing + +### Running Tests + +```bash +# All weekly goals tests +pytest tests/test_weekly_goals.py -v + +# Specific test categories +pytest tests/test_weekly_goals.py -m unit +pytest tests/test_weekly_goals.py -m models +pytest tests/test_weekly_goals.py -m smoke +``` + +### Test Coverage + +The test suite includes: +- **Model Tests**: Goal creation, calculations, status updates +- **Route Tests**: CRUD operations via web interface +- **API Tests**: All API endpoints +- **Integration Tests**: Dashboard widget, relationships + +## Troubleshooting + +### Goal Not Showing on Dashboard + +**Issue**: Current week goal created but not visible on dashboard. + +**Solutions**: +1. Refresh the page to reload goal data +2. Verify the goal is for the current week (check week_start_date) +3. Ensure goal status is not 'cancelled' + +### Progress Not Updating + +**Issue**: Logged time but progress bar hasn't moved. + +**Solutions**: +1. Ensure time entries have end_time set (not active timers) +2. Verify time entries are within the week's date range +3. Check that time entries belong to the correct user +4. Refresh the page to recalculate + +### Cannot Create Goal for Week + +**Issue**: Error when creating goal for specific week. + +**Solutions**: +1. Check if a goal already exists for that week +2. Verify target_hours is positive +3. Ensure week_start_date is a Monday (if specified) + +## Future Enhancements + +Potential future improvements: +- Goal templates (e.g., "Standard Week", "Light Week") +- Team goals and comparisons +- Goal recommendations based on historical data +- Notifications when falling behind +- Integration with calendar for automatic adjustments +- Monthly and quarterly goal aggregations +- Export goal reports + +## Related Features + +- **Time Tracking**: Time entries count toward weekly goals +- **Dashboard**: Primary interface for goal monitoring +- **Reports**: View time data that feeds into goals +- **User Preferences**: Week start day affects goal calculations + +## Support + +For issues or questions: +1. Check the [FAQ](../README.md#faq) +2. Review [Time Tracking documentation](TIME_TRACKING.md) +3. Open an issue on GitHub +4. Contact the development team + +--- + +**Last Updated**: October 24, 2025 +**Feature Version**: 1.0 +**Migration**: 027_add_weekly_time_goals + diff --git a/migrations/versions/027_add_weekly_time_goals.py b/migrations/versions/027_add_weekly_time_goals.py new file mode 100644 index 0000000..0dadbd9 --- /dev/null +++ b/migrations/versions/027_add_weekly_time_goals.py @@ -0,0 +1,79 @@ +"""Add weekly time goals table for tracking weekly hour targets + +Revision ID: 027 +Revises: 026 +Create Date: 2025-10-24 12: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() -> None: + """Create weekly_time_goals table""" + bind = op.get_bind() + inspector = sa.inspect(bind) + + # Check if weekly_time_goals table already exists + if 'weekly_time_goals' not in inspector.get_table_names(): + op.create_table('weekly_time_goals', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('target_hours', sa.Float(), nullable=False), + sa.Column('week_start_date', sa.Date(), nullable=False), + sa.Column('week_end_date', sa.Date(), nullable=False), + sa.Column('status', sa.String(length=20), nullable=False, server_default='active'), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + + # Create indexes for better performance + op.create_index('ix_weekly_time_goals_user_id', 'weekly_time_goals', ['user_id'], unique=False) + op.create_index('ix_weekly_time_goals_week_start_date', 'weekly_time_goals', ['week_start_date'], unique=False) + op.create_index('ix_weekly_time_goals_status', 'weekly_time_goals', ['status'], unique=False) + + # Create composite index for finding current week goals efficiently + op.create_index( + 'ix_weekly_time_goals_user_week', + 'weekly_time_goals', + ['user_id', 'week_start_date'], + unique=False + ) + + print("✓ Created weekly_time_goals table") + else: + print("ℹ weekly_time_goals table already exists") + + +def downgrade() -> None: + """Drop weekly_time_goals table""" + bind = op.get_bind() + inspector = sa.inspect(bind) + + # Check if weekly_time_goals table exists before trying to drop it + if 'weekly_time_goals' in inspector.get_table_names(): + try: + # Drop indexes first + op.drop_index('ix_weekly_time_goals_user_week', table_name='weekly_time_goals') + op.drop_index('ix_weekly_time_goals_status', table_name='weekly_time_goals') + op.drop_index('ix_weekly_time_goals_week_start_date', table_name='weekly_time_goals') + op.drop_index('ix_weekly_time_goals_user_id', table_name='weekly_time_goals') + + # Drop the table + op.drop_table('weekly_time_goals') + print("✓ Dropped weekly_time_goals table") + except Exception as e: + print(f"⚠ Warning dropping weekly_time_goals table: {e}") + else: + print("ℹ weekly_time_goals table does not exist") + diff --git a/tests/test_weekly_goals.py b/tests/test_weekly_goals.py new file mode 100644 index 0000000..21611ab --- /dev/null +++ b/tests/test_weekly_goals.py @@ -0,0 +1,583 @@ +""" +Test suite for Weekly Time Goals feature. +Tests model creation, calculations, relationships, routes, and business logic. +""" + +import pytest +from datetime import datetime, timedelta, date +from app.models import WeeklyTimeGoal, TimeEntry, User, Project +from app import db + + +# ============================================================================ +# WeeklyTimeGoal Model Tests +# ============================================================================ + +@pytest.mark.unit +@pytest.mark.models +@pytest.mark.smoke +def test_weekly_goal_creation(app, user): + """Test basic weekly time goal creation.""" + with app.app_context(): + week_start = date.today() - timedelta(days=date.today().weekday()) + goal = WeeklyTimeGoal( + user_id=user.id, + target_hours=40.0, + week_start_date=week_start + ) + db.session.add(goal) + db.session.commit() + + assert goal.id is not None + assert goal.target_hours == 40.0 + assert goal.week_start_date == week_start + assert goal.week_end_date == week_start + timedelta(days=6) + assert goal.status == 'active' + assert goal.created_at is not None + assert goal.updated_at is not None + + +@pytest.mark.unit +@pytest.mark.models +def test_weekly_goal_default_week(app, user): + """Test weekly goal creation with default week (current week).""" + with app.app_context(): + goal = WeeklyTimeGoal( + user_id=user.id, + target_hours=40.0 + ) + db.session.add(goal) + db.session.commit() + + # Should default to current week's Monday + today = date.today() + expected_week_start = today - timedelta(days=today.weekday()) + + assert goal.week_start_date == expected_week_start + assert goal.week_end_date == expected_week_start + timedelta(days=6) + + +@pytest.mark.unit +@pytest.mark.models +def test_weekly_goal_with_notes(app, user): + """Test weekly goal with notes.""" + with app.app_context(): + goal = WeeklyTimeGoal( + user_id=user.id, + target_hours=35.0, + notes="Vacation week, reduced hours" + ) + db.session.add(goal) + db.session.commit() + + assert goal.notes == "Vacation week, reduced hours" + + +@pytest.mark.unit +@pytest.mark.models +def test_weekly_goal_actual_hours_calculation(app, user, project): + """Test calculation of actual hours worked.""" + with app.app_context(): + week_start = date.today() - timedelta(days=date.today().weekday()) + goal = WeeklyTimeGoal( + user_id=user.id, + target_hours=40.0, + week_start_date=week_start + ) + db.session.add(goal) + db.session.commit() + + # Add time entries for the week + entry1 = TimeEntry( + user_id=user.id, + project_id=project.id, + start_time=datetime.combine(week_start, datetime.min.time()), + end_time=datetime.combine(week_start, datetime.min.time()) + timedelta(hours=8), + duration_seconds=8 * 3600 + ) + entry2 = TimeEntry( + user_id=user.id, + project_id=project.id, + start_time=datetime.combine(week_start + timedelta(days=1), datetime.min.time()), + end_time=datetime.combine(week_start + timedelta(days=1), datetime.min.time()) + timedelta(hours=7), + duration_seconds=7 * 3600 + ) + db.session.add_all([entry1, entry2]) + db.session.commit() + + # Refresh goal to get calculated properties + db.session.refresh(goal) + + assert goal.actual_hours == 15.0 + + +@pytest.mark.unit +@pytest.mark.models +def test_weekly_goal_progress_percentage(app, user, project): + """Test progress percentage calculation.""" + with app.app_context(): + week_start = date.today() - timedelta(days=date.today().weekday()) + goal = WeeklyTimeGoal( + user_id=user.id, + target_hours=40.0, + week_start_date=week_start + ) + db.session.add(goal) + db.session.commit() + + # Add time entry + entry = TimeEntry( + user_id=user.id, + project_id=project.id, + start_time=datetime.combine(week_start, datetime.min.time()), + end_time=datetime.combine(week_start, datetime.min.time()) + timedelta(hours=20), + duration_seconds=20 * 3600 + ) + db.session.add(entry) + db.session.commit() + + db.session.refresh(goal) + + # 20 hours out of 40 = 50% + assert goal.progress_percentage == 50.0 + + +@pytest.mark.unit +@pytest.mark.models +def test_weekly_goal_remaining_hours(app, user, project): + """Test remaining hours calculation.""" + with app.app_context(): + week_start = date.today() - timedelta(days=date.today().weekday()) + goal = WeeklyTimeGoal( + user_id=user.id, + target_hours=40.0, + week_start_date=week_start + ) + db.session.add(goal) + db.session.commit() + + # Add time entry + entry = TimeEntry( + user_id=user.id, + project_id=project.id, + start_time=datetime.combine(week_start, datetime.min.time()), + end_time=datetime.combine(week_start, datetime.min.time()) + timedelta(hours=15), + duration_seconds=15 * 3600 + ) + db.session.add(entry) + db.session.commit() + + db.session.refresh(goal) + + assert goal.remaining_hours == 25.0 + + +@pytest.mark.unit +@pytest.mark.models +def test_weekly_goal_is_completed(app, user, project): + """Test is_completed property.""" + with app.app_context(): + week_start = date.today() - timedelta(days=date.today().weekday()) + goal = WeeklyTimeGoal( + user_id=user.id, + target_hours=20.0, + week_start_date=week_start + ) + db.session.add(goal) + db.session.commit() + + db.session.refresh(goal) + assert goal.is_completed is False + + # Add time entry to complete goal + entry = TimeEntry( + user_id=user.id, + project_id=project.id, + start_time=datetime.combine(week_start, datetime.min.time()), + end_time=datetime.combine(week_start, datetime.min.time()) + timedelta(hours=20), + duration_seconds=20 * 3600 + ) + db.session.add(entry) + db.session.commit() + + db.session.refresh(goal) + assert goal.is_completed is True + + +@pytest.mark.unit +@pytest.mark.models +def test_weekly_goal_average_hours_per_day(app, user, project): + """Test average hours per day calculation.""" + with app.app_context(): + week_start = date.today() - timedelta(days=date.today().weekday()) + goal = WeeklyTimeGoal( + user_id=user.id, + target_hours=40.0, + week_start_date=week_start + ) + db.session.add(goal) + db.session.commit() + + # Add time entry for 10 hours + entry = TimeEntry( + user_id=user.id, + project_id=project.id, + start_time=datetime.combine(week_start, datetime.min.time()), + end_time=datetime.combine(week_start, datetime.min.time()) + timedelta(hours=10), + duration_seconds=10 * 3600 + ) + db.session.add(entry) + db.session.commit() + + db.session.refresh(goal) + + # Remaining: 30 hours, Days remaining: depends on current day + if goal.days_remaining > 0: + expected_avg = round(goal.remaining_hours / goal.days_remaining, 2) + assert goal.average_hours_per_day == expected_avg + + +@pytest.mark.unit +@pytest.mark.models +def test_weekly_goal_week_label(app, user): + """Test week label generation.""" + with app.app_context(): + week_start = date(2024, 1, 1) # A Monday + goal = WeeklyTimeGoal( + user_id=user.id, + target_hours=40.0, + week_start_date=week_start + ) + db.session.add(goal) + db.session.commit() + + assert "Jan 01" in goal.week_label + assert "Jan 07" in goal.week_label + + +@pytest.mark.unit +@pytest.mark.models +def test_weekly_goal_status_update_completed(app, user, project): + """Test automatic status update to completed.""" + with app.app_context(): + # Create goal for past week + week_start = date.today() - timedelta(days=14) + goal = WeeklyTimeGoal( + user_id=user.id, + target_hours=20.0, + week_start_date=week_start, + status='active' + ) + db.session.add(goal) + db.session.commit() + + # Add time entry to meet goal + entry = TimeEntry( + user_id=user.id, + project_id=project.id, + start_time=datetime.combine(week_start, datetime.min.time()), + end_time=datetime.combine(week_start, datetime.min.time()) + timedelta(hours=20), + duration_seconds=20 * 3600 + ) + db.session.add(entry) + db.session.commit() + + goal.update_status() + db.session.commit() + + assert goal.status == 'completed' + + +@pytest.mark.unit +@pytest.mark.models +def test_weekly_goal_status_update_failed(app, user, project): + """Test automatic status update to failed.""" + with app.app_context(): + # Create goal for past week + week_start = date.today() - timedelta(days=14) + goal = WeeklyTimeGoal( + user_id=user.id, + target_hours=40.0, + week_start_date=week_start, + status='active' + ) + db.session.add(goal) + db.session.commit() + + # Add time entry that doesn't meet goal + entry = TimeEntry( + user_id=user.id, + project_id=project.id, + start_time=datetime.combine(week_start, datetime.min.time()), + end_time=datetime.combine(week_start, datetime.min.time()) + timedelta(hours=20), + duration_seconds=20 * 3600 + ) + db.session.add(entry) + db.session.commit() + + goal.update_status() + db.session.commit() + + assert goal.status == 'failed' + + +@pytest.mark.unit +@pytest.mark.models +def test_weekly_goal_get_current_week(app, user): + """Test getting current week's goal.""" + with app.app_context(): + # Create goal for current week + today = date.today() + week_start = today - timedelta(days=today.weekday()) + + goal = WeeklyTimeGoal( + user_id=user.id, + target_hours=40.0, + week_start_date=week_start + ) + db.session.add(goal) + db.session.commit() + + # Get current week goal + current_goal = WeeklyTimeGoal.get_current_week_goal(user.id) + + assert current_goal is not None + assert current_goal.id == goal.id + + +@pytest.mark.unit +@pytest.mark.models +def test_weekly_goal_to_dict(app, user): + """Test goal serialization to dictionary.""" + with app.app_context(): + goal = WeeklyTimeGoal( + user_id=user.id, + target_hours=40.0, + notes="Test notes" + ) + db.session.add(goal) + db.session.commit() + + goal_dict = goal.to_dict() + + assert 'id' in goal_dict + assert 'user_id' in goal_dict + assert 'target_hours' in goal_dict + assert 'actual_hours' in goal_dict + assert 'week_start_date' in goal_dict + assert 'week_end_date' in goal_dict + assert 'status' in goal_dict + assert 'notes' in goal_dict + assert 'progress_percentage' in goal_dict + assert 'remaining_hours' in goal_dict + assert 'is_completed' in goal_dict + + assert goal_dict['target_hours'] == 40.0 + assert goal_dict['notes'] == "Test notes" + + +# ============================================================================ +# WeeklyTimeGoal Routes Tests +# ============================================================================ + +@pytest.mark.smoke +def test_weekly_goals_index_page(client, auth_headers): + """Test weekly goals index page loads.""" + response = client.get('/goals', headers=auth_headers) + assert response.status_code == 200 + + +@pytest.mark.smoke +def test_weekly_goals_create_page(client, auth_headers): + """Test weekly goals create page loads.""" + response = client.get('/goals/create', headers=auth_headers) + assert response.status_code == 200 + + +@pytest.mark.smoke +def test_create_weekly_goal_via_form(client, auth_headers, app, user): + """Test creating a weekly goal via form submission.""" + with app.app_context(): + data = { + 'target_hours': 40.0, + 'notes': 'Test goal' + } + response = client.post('/goals/create', data=data, headers=auth_headers, follow_redirects=True) + assert response.status_code == 200 + + # Check goal was created + goal = WeeklyTimeGoal.query.filter_by(user_id=user.id).first() + assert goal is not None + assert goal.target_hours == 40.0 + + +@pytest.mark.smoke +def test_edit_weekly_goal(client, auth_headers, app, user): + """Test editing a weekly goal.""" + with app.app_context(): + goal = WeeklyTimeGoal( + user_id=user.id, + target_hours=40.0 + ) + db.session.add(goal) + db.session.commit() + goal_id = goal.id + + # Update goal + data = { + 'target_hours': 35.0, + 'notes': 'Updated notes', + 'status': 'active' + } + response = client.post(f'/goals/{goal_id}/edit', data=data, headers=auth_headers, follow_redirects=True) + assert response.status_code == 200 + + # Check goal was updated + db.session.refresh(goal) + assert goal.target_hours == 35.0 + assert goal.notes == 'Updated notes' + + +@pytest.mark.smoke +def test_delete_weekly_goal(client, auth_headers, app, user): + """Test deleting a weekly goal.""" + with app.app_context(): + goal = WeeklyTimeGoal( + user_id=user.id, + target_hours=40.0 + ) + db.session.add(goal) + db.session.commit() + goal_id = goal.id + + # Delete goal + response = client.post(f'/goals/{goal_id}/delete', headers=auth_headers, follow_redirects=True) + assert response.status_code == 200 + + # Check goal was deleted + deleted_goal = WeeklyTimeGoal.query.get(goal_id) + assert deleted_goal is None + + +@pytest.mark.smoke +def test_view_weekly_goal(client, auth_headers, app, user): + """Test viewing a specific weekly goal.""" + with app.app_context(): + goal = WeeklyTimeGoal( + user_id=user.id, + target_hours=40.0 + ) + db.session.add(goal) + db.session.commit() + goal_id = goal.id + + response = client.get(f'/goals/{goal_id}', headers=auth_headers) + assert response.status_code == 200 + + +# ============================================================================ +# API Endpoints Tests +# ============================================================================ + +@pytest.mark.smoke +def test_api_get_current_goal(client, auth_headers, app, user): + """Test API endpoint for getting current week's goal.""" + with app.app_context(): + goal = WeeklyTimeGoal( + user_id=user.id, + target_hours=40.0 + ) + db.session.add(goal) + db.session.commit() + + response = client.get('/api/goals/current', headers=auth_headers) + assert response.status_code == 200 + + data = response.get_json() + assert 'target_hours' in data + assert data['target_hours'] == 40.0 + + +@pytest.mark.smoke +def test_api_list_goals(client, auth_headers, app, user): + """Test API endpoint for listing goals.""" + with app.app_context(): + # Create multiple goals + for i in range(3): + goal = WeeklyTimeGoal( + user_id=user.id, + target_hours=40.0, + week_start_date=date.today() - timedelta(weeks=i, days=date.today().weekday()) + ) + db.session.add(goal) + db.session.commit() + + response = client.get('/api/goals', headers=auth_headers) + assert response.status_code == 200 + + data = response.get_json() + assert isinstance(data, list) + assert len(data) == 3 + + +@pytest.mark.smoke +def test_api_get_goal_stats(client, auth_headers, app, user, project): + """Test API endpoint for goal statistics.""" + with app.app_context(): + # Create goals with different statuses + week_start = date.today() - timedelta(days=21) + for i, status in enumerate(['completed', 'failed', 'active']): + goal = WeeklyTimeGoal( + user_id=user.id, + target_hours=40.0, + week_start_date=week_start + timedelta(weeks=i), + status=status + ) + db.session.add(goal) + db.session.commit() + + response = client.get('/api/goals/stats', headers=auth_headers) + assert response.status_code == 200 + + data = response.get_json() + assert 'total_goals' in data + assert 'completed' in data + assert 'failed' in data + assert 'completion_rate' in data + assert data['total_goals'] == 3 + assert data['completed'] == 1 + assert data['failed'] == 1 + + +@pytest.mark.unit +def test_weekly_goal_user_relationship(app, user): + """Test weekly goal user relationship.""" + with app.app_context(): + goal = WeeklyTimeGoal( + user_id=user.id, + target_hours=40.0 + ) + db.session.add(goal) + db.session.commit() + + db.session.refresh(goal) + assert goal.user is not None + assert goal.user.id == user.id + + +@pytest.mark.unit +def test_user_has_weekly_goals_relationship(app, user): + """Test that user has weekly_goals relationship.""" + with app.app_context(): + goal1 = WeeklyTimeGoal(user_id=user.id, target_hours=40.0) + goal2 = WeeklyTimeGoal( + user_id=user.id, + target_hours=35.0, + week_start_date=date.today() - timedelta(weeks=1, days=date.today().weekday()) + ) + db.session.add_all([goal1, goal2]) + db.session.commit() + + db.session.refresh(user) + assert user.weekly_goals.count() >= 2 + From ede1f489fbec4c8762dca1529d6287ddba55b10c Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Fri, 24 Oct 2025 10:18:34 +0200 Subject: [PATCH 2/2] Update alembic migration. --- ..._weekly_time_goals.py => 028_add_weekly_time_goals.py} | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) rename migrations/versions/{027_add_weekly_time_goals.py => 028_add_weekly_time_goals.py} (97%) diff --git a/migrations/versions/027_add_weekly_time_goals.py b/migrations/versions/028_add_weekly_time_goals.py similarity index 97% rename from migrations/versions/027_add_weekly_time_goals.py rename to migrations/versions/028_add_weekly_time_goals.py index 0dadbd9..d165aa0 100644 --- a/migrations/versions/027_add_weekly_time_goals.py +++ b/migrations/versions/028_add_weekly_time_goals.py @@ -1,7 +1,7 @@ """Add weekly time goals table for tracking weekly hour targets -Revision ID: 027 -Revises: 026 +Revision ID: 028 +Revises: 027 Create Date: 2025-10-24 12:00:00 """ @@ -10,8 +10,8 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision = '027' -down_revision = '026' +revision = '028' +down_revision = '027' branch_labels = None depends_on = None