Merge pull request #143 from DRYTRIX/Feat-Weekly-Time-Goals

feat: Add Weekly Time Goals feature for tracking weekly hour targets
This commit is contained in:
Dries Peeters
2025-10-24 10:20:13 +02:00
committed by GitHub
14 changed files with 2399 additions and 2 deletions

View File

@@ -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

View File

@@ -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",
]

View 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

View File

@@ -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
View 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
})

View File

@@ -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>

View File

@@ -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">

View 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 %}

View 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 %}

View 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 %}

View 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
View 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

View File

@@ -0,0 +1,79 @@
"""Add weekly time goals table for tracking weekly hour targets
Revision ID: 028
Revises: 027
Create Date: 2025-10-24 12:00:00
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '028'
down_revision = '027'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Create weekly_time_goals table"""
bind = op.get_bind()
inspector = sa.inspect(bind)
# Check if weekly_time_goals table already exists
if 'weekly_time_goals' not in inspector.get_table_names():
op.create_table('weekly_time_goals',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('target_hours', sa.Float(), nullable=False),
sa.Column('week_start_date', sa.Date(), nullable=False),
sa.Column('week_end_date', sa.Date(), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False, server_default='active'),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
# Create indexes for better performance
op.create_index('ix_weekly_time_goals_user_id', 'weekly_time_goals', ['user_id'], unique=False)
op.create_index('ix_weekly_time_goals_week_start_date', 'weekly_time_goals', ['week_start_date'], unique=False)
op.create_index('ix_weekly_time_goals_status', 'weekly_time_goals', ['status'], unique=False)
# Create composite index for finding current week goals efficiently
op.create_index(
'ix_weekly_time_goals_user_week',
'weekly_time_goals',
['user_id', 'week_start_date'],
unique=False
)
print("✓ Created weekly_time_goals table")
else:
print(" weekly_time_goals table already exists")
def downgrade() -> None:
"""Drop weekly_time_goals table"""
bind = op.get_bind()
inspector = sa.inspect(bind)
# Check if weekly_time_goals table exists before trying to drop it
if 'weekly_time_goals' in inspector.get_table_names():
try:
# Drop indexes first
op.drop_index('ix_weekly_time_goals_user_week', table_name='weekly_time_goals')
op.drop_index('ix_weekly_time_goals_status', table_name='weekly_time_goals')
op.drop_index('ix_weekly_time_goals_week_start_date', table_name='weekly_time_goals')
op.drop_index('ix_weekly_time_goals_user_id', table_name='weekly_time_goals')
# Drop the table
op.drop_table('weekly_time_goals')
print("✓ Dropped weekly_time_goals table")
except Exception as e:
print(f"⚠ Warning dropping weekly_time_goals table: {e}")
else:
print(" weekly_time_goals table does not exist")

583
tests/test_weekly_goals.py Normal file
View 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