mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-02-21 13:08:43 -06:00
feat: Add Weekly Time Goals feature for tracking weekly hour targets
Implemented a comprehensive Weekly Time Goals system that allows users to set and track weekly hour targets with real-time progress monitoring. Features: - WeeklyTimeGoal model with status tracking (active, completed, failed, cancelled) - Full CRUD interface for managing weekly goals - Real-time progress calculation based on logged time entries - Dashboard widget showing current week's goal progress - Daily breakdown view with detailed statistics - Automatic status updates based on goal completion and week end - API endpoints for goal data and progress tracking Technical changes: - Added app/models/weekly_time_goal.py with local timezone support - Created migration 027_add_weekly_time_goals.py for database schema - Added app/routes/weekly_goals.py blueprint with all CRUD routes - Created templates: index.html, create.html, edit.html, view.html - Integrated weekly goal widget into main dashboard - Added "Weekly Goals" navigation item to sidebar - Implemented comprehensive test suite in tests/test_weekly_goals.py - Added feature documentation in docs/WEEKLY_TIME_GOALS.md Bug fixes: - Fixed timezone handling to use TZ environment variable instead of Config.TIMEZONE - Corrected log_event() calls to use proper signature (event name as first positional argument) - Manually created database table via SQL when Alembic migration didn't execute Database schema: - weekly_time_goals table with user_id, target_hours, week_start_date, week_end_date, status, notes - Indexes on user_id, week_start_date, status, and composite (user_id, week_start_date) - Foreign key constraint to users table with CASCADE delete The feature supports flexible week start days per user, calculates remaining hours, provides daily average targets, and automatically updates goal status based on progress.
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/027_add_weekly_time_goals.py
Normal file
79
migrations/versions/027_add_weekly_time_goals.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Add weekly time goals table for tracking weekly hour targets
|
||||
|
||||
Revision ID: 027
|
||||
Revises: 026
|
||||
Create Date: 2025-10-24 12:00:00
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '027'
|
||||
down_revision = '026'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create weekly_time_goals table"""
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
# Check if weekly_time_goals table already exists
|
||||
if 'weekly_time_goals' not in inspector.get_table_names():
|
||||
op.create_table('weekly_time_goals',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('target_hours', sa.Float(), nullable=False),
|
||||
sa.Column('week_start_date', sa.Date(), nullable=False),
|
||||
sa.Column('week_end_date', sa.Date(), nullable=False),
|
||||
sa.Column('status', sa.String(length=20), nullable=False, server_default='active'),
|
||||
sa.Column('notes', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
# Create indexes for better performance
|
||||
op.create_index('ix_weekly_time_goals_user_id', 'weekly_time_goals', ['user_id'], unique=False)
|
||||
op.create_index('ix_weekly_time_goals_week_start_date', 'weekly_time_goals', ['week_start_date'], unique=False)
|
||||
op.create_index('ix_weekly_time_goals_status', 'weekly_time_goals', ['status'], unique=False)
|
||||
|
||||
# Create composite index for finding current week goals efficiently
|
||||
op.create_index(
|
||||
'ix_weekly_time_goals_user_week',
|
||||
'weekly_time_goals',
|
||||
['user_id', 'week_start_date'],
|
||||
unique=False
|
||||
)
|
||||
|
||||
print("✓ Created weekly_time_goals table")
|
||||
else:
|
||||
print("ℹ weekly_time_goals table already exists")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop weekly_time_goals table"""
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
# Check if weekly_time_goals table exists before trying to drop it
|
||||
if 'weekly_time_goals' in inspector.get_table_names():
|
||||
try:
|
||||
# Drop indexes first
|
||||
op.drop_index('ix_weekly_time_goals_user_week', table_name='weekly_time_goals')
|
||||
op.drop_index('ix_weekly_time_goals_status', table_name='weekly_time_goals')
|
||||
op.drop_index('ix_weekly_time_goals_week_start_date', table_name='weekly_time_goals')
|
||||
op.drop_index('ix_weekly_time_goals_user_id', table_name='weekly_time_goals')
|
||||
|
||||
# Drop the table
|
||||
op.drop_table('weekly_time_goals')
|
||||
print("✓ Dropped weekly_time_goals table")
|
||||
except Exception as e:
|
||||
print(f"⚠ Warning dropping weekly_time_goals table: {e}")
|
||||
else:
|
||||
print("ℹ weekly_time_goals table does not exist")
|
||||
|
||||
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