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 6ecfa92..c708fb4 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/028_add_weekly_time_goals.py b/migrations/versions/028_add_weekly_time_goals.py new file mode 100644 index 0000000..d165aa0 --- /dev/null +++ b/migrations/versions/028_add_weekly_time_goals.py @@ -0,0 +1,79 @@ +"""Add weekly time goals table for tracking weekly hour targets + +Revision ID: 028 +Revises: 027 +Create Date: 2025-10-24 12:00:00 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '028' +down_revision = '027' +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 +