mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-06 19:51:25 -06:00
Merge pull request #143 from DRYTRIX/Feat-Weekly-Time-Goals
feat: Add Weekly Time Goals feature for tracking weekly hour targets
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
202
app/models/weekly_time_goal.py
Normal file
202
app/models/weekly_time_goal.py
Normal file
@@ -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'<WeeklyTimeGoal user_id={self.user_id} week={self.week_start_date} target={self.target_hours}h>'
|
||||
|
||||
@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
|
||||
|
||||
@@ -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():
|
||||
|
||||
399
app/routes/weekly_goals.py
Normal file
399
app/routes/weekly_goals.py
Normal file
@@ -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/<int:goal_id>')
|
||||
@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/<int:goal_id>/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/<int:goal_id>/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/<int:goal_id>')
|
||||
@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
|
||||
})
|
||||
|
||||
@@ -114,6 +114,12 @@
|
||||
<span class="ml-3 sidebar-label">{{ _('Dashboard') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="mt-2">
|
||||
<a href="{{ url_for('weekly_goals.index') }}" class="flex items-center p-2 rounded-lg {% if ep.startswith('weekly_goals.') %}bg-background-light dark:bg-background-dark text-primary font-semibold{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
|
||||
<i class="fas fa-bullseye w-6 text-center"></i>
|
||||
<span class="ml-3 sidebar-label">{{ _('Weekly Goals') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="mt-2">
|
||||
<button onclick="toggleDropdown('workDropdown')" data-dropdown="workDropdown" class="w-full flex items-center p-2 rounded-lg {% if work_open %}bg-background-light dark:bg-background-dark text-primary font-semibold{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
|
||||
<i class="fas fa-briefcase w-6 text-center"></i>
|
||||
|
||||
@@ -104,6 +104,63 @@
|
||||
|
||||
<!-- Right Column: Real Insights -->
|
||||
<div class="space-y-6">
|
||||
<!-- Weekly Goal Widget -->
|
||||
{% if current_week_goal %}
|
||||
<div class="bg-gradient-to-br from-blue-500 to-purple-600 p-6 rounded-lg shadow-lg animated-card text-white">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold">
|
||||
<i class="fas fa-bullseye mr-2"></i>
|
||||
{{ _('Weekly Goal') }}
|
||||
</h2>
|
||||
<a href="{{ url_for('weekly_goals.index') }}" class="text-white hover:text-gray-200 transition">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<div class="flex justify-between text-sm mb-2 opacity-90">
|
||||
<span>{{ current_week_goal.actual_hours }}h / {{ current_week_goal.target_hours }}h</span>
|
||||
<span>{{ current_week_goal.progress_percentage }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-white bg-opacity-30 rounded-full h-3">
|
||||
<div class="bg-white rounded-full h-3 transition-all duration-300"
|
||||
style="width: {{ current_week_goal.progress_percentage }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3 text-sm">
|
||||
<div class="bg-white bg-opacity-20 rounded p-2">
|
||||
<div class="opacity-90 text-xs">{{ _('Remaining') }}</div>
|
||||
<div class="font-semibold">{{ current_week_goal.remaining_hours }}h</div>
|
||||
</div>
|
||||
<div class="bg-white bg-opacity-20 rounded p-2">
|
||||
<div class="opacity-90 text-xs">{{ _('Days Left') }}</div>
|
||||
<div class="font-semibold">{{ current_week_goal.days_remaining }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if current_week_goal.days_remaining > 0 and current_week_goal.remaining_hours > 0 %}
|
||||
<div class="mt-3 text-sm opacity-90">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
{{ _('Need') }} {{ current_week_goal.average_hours_per_day }}h/day {{ _('to reach goal') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow animated-card border-2 border-dashed border-gray-300 dark:border-gray-600">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-bullseye text-4xl text-gray-400 mb-3"></i>
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ _('No Weekly Goal') }}
|
||||
</h3>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-4">
|
||||
{{ _('Set a weekly time goal to track your progress') }}
|
||||
</p>
|
||||
<a href="{{ url_for('weekly_goals.create') }}"
|
||||
class="inline-block bg-blue-600 text-white px-4 py-2 rounded-lg text-sm hover:bg-blue-700 transition">
|
||||
<i class="fas fa-plus mr-2"></i> {{ _('Create Goal') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow animated-card">
|
||||
<h2 class="text-lg font-semibold mb-4">{{ _('Top Projects (30 days)') }}</h2>
|
||||
<ul class="space-y-3">
|
||||
|
||||
137
app/templates/weekly_goals/create.html
Normal file
137
app/templates/weekly_goals/create.html
Normal file
@@ -0,0 +1,137 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ _('Create Weekly Goal') }} - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8 max-w-2xl">
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
<i class="fas fa-bullseye mr-2 text-blue-600"></i>
|
||||
{{ _('Create Weekly Time Goal') }}
|
||||
</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-2">
|
||||
{{ _('Set a target for hours to work this week') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<form method="POST" action="{{ url_for('weekly_goals.create') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<!-- Target Hours -->
|
||||
<div class="mb-6">
|
||||
<label for="target_hours" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ _('Target Hours') }} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input type="number"
|
||||
id="target_hours"
|
||||
name="target_hours"
|
||||
step="0.5"
|
||||
min="1"
|
||||
max="168"
|
||||
required
|
||||
value="40"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
<span class="absolute right-4 top-2 text-gray-500 dark:text-gray-400">hours</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{{ _('How many hours do you want to work this week?') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Week Start Date -->
|
||||
<div class="mb-6">
|
||||
<label for="week_start_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ _('Week Start Date') }}
|
||||
</label>
|
||||
<input type="date"
|
||||
id="week_start_date"
|
||||
name="week_start_date"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{{ _('Leave blank to use current week (starting Monday)') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="mb-6">
|
||||
<label for="notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ _('Notes') }}
|
||||
</label>
|
||||
<textarea id="notes"
|
||||
name="notes"
|
||||
rows="3"
|
||||
placeholder="{{ _('Optional notes about your goal...') }}"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Quick Presets -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ _('Quick Presets') }}
|
||||
</label>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
<button type="button"
|
||||
onclick="document.getElementById('target_hours').value = 20"
|
||||
class="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition">
|
||||
20h
|
||||
</button>
|
||||
<button type="button"
|
||||
onclick="document.getElementById('target_hours').value = 30"
|
||||
class="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition">
|
||||
30h
|
||||
</button>
|
||||
<button type="button"
|
||||
onclick="document.getElementById('target_hours').value = 40"
|
||||
class="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition">
|
||||
40h
|
||||
</button>
|
||||
<button type="button"
|
||||
onclick="document.getElementById('target_hours').value = 50"
|
||||
class="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition">
|
||||
50h
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-between space-x-4">
|
||||
<a href="{{ url_for('weekly_goals.index') }}"
|
||||
class="px-6 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition">
|
||||
<i class="fas fa-times mr-2"></i> {{ _('Cancel') }}
|
||||
</a>
|
||||
<button type="submit"
|
||||
class="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition">
|
||||
<i class="fas fa-check mr-2"></i> {{ _('Create Goal') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Tips -->
|
||||
<div class="mt-6 bg-blue-50 dark:bg-blue-900 border-l-4 border-blue-400 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-lightbulb text-blue-500"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-blue-900 dark:text-blue-100">
|
||||
{{ _('Tips for Setting Goals') }}
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-blue-700 dark:text-blue-300">
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li>{{ _('Be realistic: Consider holidays, meetings, and other commitments') }}</li>
|
||||
<li>{{ _('Start conservative: You can always adjust your goal later') }}</li>
|
||||
<li>{{ _('Track progress: Check your dashboard regularly to stay on track') }}</li>
|
||||
<li>{{ _('Typical full-time: 40 hours per week (8 hours/day, 5 days)') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
112
app/templates/weekly_goals/edit.html
Normal file
112
app/templates/weekly_goals/edit.html
Normal file
@@ -0,0 +1,112 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ _('Edit Weekly Goal') }} - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8 max-w-2xl">
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
<i class="fas fa-edit mr-2 text-blue-600"></i>
|
||||
{{ _('Edit Weekly Time Goal') }}
|
||||
</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-2">
|
||||
{{ goal.week_label }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<form method="POST" action="{{ url_for('weekly_goals.edit', goal_id=goal.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<!-- Week Info (Read-only) -->
|
||||
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{ _('Week Period') }}</p>
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ goal.week_label }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{ _('Current Progress') }}</p>
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ goal.actual_hours }}h / {{ goal.target_hours }}h ({{ goal.progress_percentage }}%)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Target Hours -->
|
||||
<div class="mb-6">
|
||||
<label for="target_hours" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ _('Target Hours') }} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input type="number"
|
||||
id="target_hours"
|
||||
name="target_hours"
|
||||
step="0.5"
|
||||
min="1"
|
||||
max="168"
|
||||
required
|
||||
value="{{ goal.target_hours }}"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
<span class="absolute right-4 top-2 text-gray-500 dark:text-gray-400">hours</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="mb-6">
|
||||
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ _('Status') }}
|
||||
</label>
|
||||
<select id="status"
|
||||
name="status"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||
<option value="active" {% if goal.status == 'active' %}selected{% endif %}>{{ _('Active') }}</option>
|
||||
<option value="completed" {% if goal.status == 'completed' %}selected{% endif %}>{{ _('Completed') }}</option>
|
||||
<option value="failed" {% if goal.status == 'failed' %}selected{% endif %}>{{ _('Failed') }}</option>
|
||||
<option value="cancelled" {% if goal.status == 'cancelled' %}selected{% endif %}>{{ _('Cancelled') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="mb-6">
|
||||
<label for="notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ _('Notes') }}
|
||||
</label>
|
||||
<textarea id="notes"
|
||||
name="notes"
|
||||
rows="3"
|
||||
placeholder="{{ _('Optional notes about your goal...') }}"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">{{ goal.notes or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-between">
|
||||
<div class="space-x-2">
|
||||
<a href="{{ url_for('weekly_goals.view', goal_id=goal.id) }}"
|
||||
class="px-6 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition">
|
||||
<i class="fas fa-times mr-2"></i> {{ _('Cancel') }}
|
||||
</a>
|
||||
<button type="button"
|
||||
onclick="event.preventDefault(); window.showConfirm('{{ _('Are you sure you want to delete this goal?') }}', { title: '{{ _('Delete Goal') }}', confirmText: '{{ _('Delete') }}', variant: 'danger' }).then(ok=>{ if(ok) document.getElementById('deleteForm').submit(); });"
|
||||
class="px-6 py-2 border border-red-300 dark:border-red-600 text-red-700 dark:text-red-300 rounded-lg hover:bg-red-50 dark:hover:bg-red-900 transition">
|
||||
<i class="fas fa-trash mr-2"></i> {{ _('Delete') }}
|
||||
</button>
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition">
|
||||
<i class="fas fa-save mr-2"></i> {{ _('Save Changes') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Delete Form (Hidden) -->
|
||||
<form id="deleteForm" method="POST" action="{{ url_for('weekly_goals.delete', goal_id=goal.id) }}" style="display: none;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
229
app/templates/weekly_goals/index.html
Normal file
229
app/templates/weekly_goals/index.html
Normal file
@@ -0,0 +1,229 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ _('Weekly Time Goals') }} - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
<i class="fas fa-bullseye mr-2 text-blue-600"></i>
|
||||
{{ _('Weekly Time Goals') }}
|
||||
</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-2">
|
||||
{{ _('Set and track your weekly hour targets') }}
|
||||
</p>
|
||||
</div>
|
||||
<a href="{{ url_for('weekly_goals.create') }}" class="btn btn-primary">
|
||||
<i class="fas fa-plus mr-2"></i> {{ _('New Goal') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Statistics -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-trophy text-3xl text-yellow-500"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{ _('Total Goals') }}</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ stats.total_goals }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-check-circle text-3xl text-green-500"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{ _('Completed') }}</p>
|
||||
<p class="text-2xl font-bold text-green-600 dark:text-green-400">{{ stats.completed }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-times-circle text-3xl text-red-500"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{ _('Failed') }}</p>
|
||||
<p class="text-2xl font-bold text-red-600 dark:text-red-400">{{ stats.failed }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-percentage text-3xl text-blue-500"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{ _('Success Rate') }}</p>
|
||||
<p class="text-2xl font-bold text-blue-600 dark:text-blue-400">{{ stats.completion_rate }}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current Week Goal -->
|
||||
{% if current_goal %}
|
||||
<div class="bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg shadow-lg p-6 mb-6 text-white">
|
||||
<h2 class="text-2xl font-bold mb-4">
|
||||
<i class="fas fa-calendar-week mr-2"></i>
|
||||
{{ _('Current Week Goal') }}
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<p class="text-sm opacity-90">{{ _('Week') }}</p>
|
||||
<p class="text-xl font-bold">{{ current_goal.week_label }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm opacity-90">{{ _('Target Hours') }}</p>
|
||||
<p class="text-xl font-bold">{{ current_goal.target_hours }}h</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm opacity-90">{{ _('Actual Hours') }}</p>
|
||||
<p class="text-xl font-bold">{{ current_goal.actual_hours }}h</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="mb-4">
|
||||
<div class="flex justify-between text-sm mb-2">
|
||||
<span>{{ _('Progress') }}</span>
|
||||
<span>{{ current_goal.progress_percentage }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-white bg-opacity-30 rounded-full h-4">
|
||||
<div class="bg-white rounded-full h-4 transition-all duration-300"
|
||||
style="width: {{ current_goal.progress_percentage }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p class="text-sm opacity-90">{{ _('Remaining Hours') }}</p>
|
||||
<p class="text-lg font-semibold">{{ current_goal.remaining_hours }}h</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm opacity-90">{{ _('Days Remaining') }}</p>
|
||||
<p class="text-lg font-semibold">{{ current_goal.days_remaining }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm opacity-90">{{ _('Avg Hours/Day Needed') }}</p>
|
||||
<p class="text-lg font-semibold">{{ current_goal.average_hours_per_day }}h</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex space-x-2">
|
||||
<a href="{{ url_for('weekly_goals.view', goal_id=current_goal.id) }}"
|
||||
class="bg-white text-blue-600 px-4 py-2 rounded hover:bg-opacity-90 transition">
|
||||
<i class="fas fa-eye mr-2"></i> {{ _('View Details') }}
|
||||
</a>
|
||||
<a href="{{ url_for('weekly_goals.edit', goal_id=current_goal.id) }}"
|
||||
class="bg-white bg-opacity-20 text-white px-4 py-2 rounded hover:bg-opacity-30 transition">
|
||||
<i class="fas fa-edit mr-2"></i> {{ _('Edit Goal') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- No Current Goal -->
|
||||
<div class="bg-yellow-50 dark:bg-yellow-900 border-l-4 border-yellow-400 p-6 mb-6">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-exclamation-triangle text-2xl text-yellow-500"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<h3 class="text-lg font-semibold text-yellow-900 dark:text-yellow-100">
|
||||
{{ _('No goal set for this week') }}
|
||||
</h3>
|
||||
<p class="text-yellow-700 dark:text-yellow-300 mt-1">
|
||||
{{ _('Create a weekly time goal to start tracking your progress') }}
|
||||
</p>
|
||||
<a href="{{ url_for('weekly_goals.create') }}"
|
||||
class="inline-block mt-3 bg-yellow-500 text-white px-4 py-2 rounded hover:bg-yellow-600 transition">
|
||||
<i class="fas fa-plus mr-2"></i> {{ _('Create Goal') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Past Goals -->
|
||||
{% if goals %}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
<i class="fas fa-history mr-2 text-gray-600"></i>
|
||||
{{ _('Goal History') }}
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
{% for goal in goals %}
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-lg transition">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-2">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ goal.week_label }}
|
||||
</h3>
|
||||
{% if goal.status == 'completed' %}
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
<i class="fas fa-check mr-1"></i> {{ _('Completed') }}
|
||||
</span>
|
||||
{% elif goal.status == 'active' %}
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
<i class="fas fa-clock mr-1"></i> {{ _('Active') }}
|
||||
</span>
|
||||
{% elif goal.status == 'failed' %}
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
|
||||
<i class="fas fa-times mr-1"></i> {{ _('Failed') }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
{{ _('Target') }}: {{ goal.target_hours }}h | {{ _('Actual') }}: {{ goal.actual_hours }}h
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<a href="{{ url_for('weekly_goals.view', goal_id=goal.id) }}"
|
||||
class="text-blue-600 hover:text-blue-800 dark:text-blue-400"
|
||||
title="{{ _('View') }}">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
<a href="{{ url_for('weekly_goals.edit', goal_id=goal.id) }}"
|
||||
class="text-gray-600 hover:text-gray-800 dark:text-gray-400"
|
||||
title="{{ _('Edit') }}">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="mb-2">
|
||||
<div class="flex justify-between text-xs text-gray-600 dark:text-gray-400 mb-1">
|
||||
<span>{{ _('Progress') }}</span>
|
||||
<span>{{ goal.progress_percentage }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
{% if goal.status == 'completed' %}
|
||||
<div class="bg-green-500 rounded-full h-2" style="width: {{ goal.progress_percentage }}%"></div>
|
||||
{% elif goal.status == 'failed' %}
|
||||
<div class="bg-red-500 rounded-full h-2" style="width: {{ goal.progress_percentage }}%"></div>
|
||||
{% else %}
|
||||
<div class="bg-blue-500 rounded-full h-2" style="width: {{ goal.progress_percentage }}%"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
214
app/templates/weekly_goals/view.html
Normal file
214
app/templates/weekly_goals/view.html
Normal file
@@ -0,0 +1,214 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ _('Weekly Goal Details') }} - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
<i class="fas fa-bullseye mr-2 text-blue-600"></i>
|
||||
{{ _('Weekly Goal Details') }}
|
||||
</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-2">
|
||||
{{ goal.week_label }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<a href="{{ url_for('weekly_goals.edit', goal_id=goal.id) }}"
|
||||
class="btn btn-secondary">
|
||||
<i class="fas fa-edit mr-2"></i> {{ _('Edit') }}
|
||||
</a>
|
||||
<a href="{{ url_for('weekly_goals.index') }}"
|
||||
class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left mr-2"></i> {{ _('Back') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Goal Overview -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('Target Hours') }}</h3>
|
||||
<i class="fas fa-bullseye text-2xl text-blue-500"></i>
|
||||
</div>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-white">{{ goal.target_hours }}h</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('Actual Hours') }}</h3>
|
||||
<i class="fas fa-clock text-2xl text-green-500"></i>
|
||||
</div>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-white">{{ goal.actual_hours }}h</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('Status') }}</h3>
|
||||
{% if goal.status == 'completed' %}
|
||||
<i class="fas fa-check-circle text-2xl text-green-500"></i>
|
||||
{% elif goal.status == 'active' %}
|
||||
<i class="fas fa-clock text-2xl text-blue-500"></i>
|
||||
{% elif goal.status == 'failed' %}
|
||||
<i class="fas fa-times-circle text-2xl text-red-500"></i>
|
||||
{% else %}
|
||||
<i class="fas fa-ban text-2xl text-gray-500"></i>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if goal.status == 'completed' %}
|
||||
<p class="text-xl font-bold text-green-600 dark:text-green-400">{{ _('Completed') }}</p>
|
||||
{% elif goal.status == 'active' %}
|
||||
<p class="text-xl font-bold text-blue-600 dark:text-blue-400">{{ _('Active') }}</p>
|
||||
{% elif goal.status == 'failed' %}
|
||||
<p class="text-xl font-bold text-red-600 dark:text-red-400">{{ _('Failed') }}</p>
|
||||
{% else %}
|
||||
<p class="text-xl font-bold text-gray-600 dark:text-gray-400">{{ _('Cancelled') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Card -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
<i class="fas fa-chart-line mr-2"></i>
|
||||
{{ _('Progress') }}
|
||||
</h2>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="flex justify-between text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
<span>{{ goal.actual_hours }}h / {{ goal.target_hours }}h</span>
|
||||
<span>{{ goal.progress_percentage }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-4">
|
||||
{% if goal.status == 'completed' %}
|
||||
<div class="bg-green-500 rounded-full h-4 transition-all duration-300"
|
||||
style="width: {{ goal.progress_percentage }}%"></div>
|
||||
{% elif goal.status == 'failed' %}
|
||||
<div class="bg-red-500 rounded-full h-4 transition-all duration-300"
|
||||
style="width: {{ goal.progress_percentage }}%"></div>
|
||||
{% else %}
|
||||
<div class="bg-blue-500 rounded-full h-4 transition-all duration-300"
|
||||
style="width: {{ goal.progress_percentage }}%"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-6">
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">{{ _('Remaining Hours') }}</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ goal.remaining_hours }}h</p>
|
||||
</div>
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">{{ _('Days Remaining') }}</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ goal.days_remaining }}</p>
|
||||
</div>
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">{{ _('Avg Hours/Day Needed') }}</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ goal.average_hours_per_day }}h</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Daily Breakdown -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
<i class="fas fa-calendar-alt mr-2"></i>
|
||||
{{ _('Daily Breakdown') }}
|
||||
</h2>
|
||||
|
||||
<div class="space-y-2">
|
||||
{% for date, hours in daily_hours.items() %}
|
||||
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-white">
|
||||
{{ date.strftime('%A, %B %d') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ "%.2f"|format(hours) }} hours
|
||||
</span>
|
||||
<div class="w-32 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
{% set daily_target = goal.target_hours / 7 %}
|
||||
{% set daily_percentage = (hours / daily_target * 100) if daily_target > 0 else 0 %}
|
||||
<div class="bg-blue-500 rounded-full h-2"
|
||||
style="width: {{ [daily_percentage, 100]|min }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
{% if goal.notes %}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
<i class="fas fa-sticky-note mr-2"></i>
|
||||
{{ _('Notes') }}
|
||||
</h2>
|
||||
<p class="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">{{ goal.notes }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Time Entries -->
|
||||
{% if time_entries %}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
<i class="fas fa-list mr-2"></i>
|
||||
{{ _('Time Entries This Week') }} ({{ time_entries|length }})
|
||||
</h2>
|
||||
|
||||
<div class="space-y-2">
|
||||
{% for entry in time_entries %}
|
||||
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-900 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
{{ entry.project.name if entry.project else _('No Project') }}
|
||||
</span>
|
||||
{% if entry.task %}
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||
• {{ entry.task.name }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
{{ entry.start_time.strftime('%a, %b %d at %H:%M') }}
|
||||
{% if entry.notes %}
|
||||
• {{ entry.notes[:50] }}{% if entry.notes|length > 50 %}...{% endif %}
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-semibold text-gray-900 dark:text-white">
|
||||
{{ entry.duration_formatted }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ "%.2f"|format(entry.duration_seconds / 3600) }}h
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="bg-yellow-50 dark:bg-yellow-900 border-l-4 border-yellow-400 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-500"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-yellow-700 dark:text-yellow-300">
|
||||
{{ _('No time entries recorded for this week yet') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
369
docs/WEEKLY_TIME_GOALS.md
Normal file
369
docs/WEEKLY_TIME_GOALS.md
Normal file
@@ -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/<id>` - View specific goal
|
||||
- `GET /goals/<id>/edit` - Edit goal form
|
||||
- `POST /goals/<id>/edit` - Update goal handler
|
||||
- `POST /goals/<id>/delete` - Delete goal handler
|
||||
|
||||
**API Routes:**
|
||||
- `GET /api/goals/current` - Get current week goal
|
||||
- `GET /api/goals` - List goals
|
||||
- `GET /api/goals/<id>` - 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
|
||||
|
||||
79
migrations/versions/028_add_weekly_time_goals.py
Normal file
79
migrations/versions/028_add_weekly_time_goals.py
Normal file
@@ -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")
|
||||
|
||||
583
tests/test_weekly_goals.py
Normal file
583
tests/test_weekly_goals.py
Normal file
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user