feat: Focus mode, estimates/burndown+budget alerts, recurring blocks, saved filters, and rate overrides

Add Pomodoro focus mode with session summaries
Model: FocusSession; API: /api/focus-sessions/; UI: Focus modal on timer page
Add estimates vs actuals with burndown and budget alerts
Project fields: estimated_hours, budget_amount, budget_threshold_percent
API: /api/projects/<id>/burndown; Charts in project view and project report
Implement recurring time blocks/templates
Model: RecurringBlock; API CRUD: /api/recurring-blocks; CLI: flask generate_recurring
Add tagging and saved filters across views
Model: SavedFilter; /api/entries supports tag and saved_filter_id
Support billable rate overrides per project/member
Model: RateOverride; invoicing uses effective rate resolution
Also:
Migration: 016_add_focus_recurring_rates_filters_and_project_budget.py
Integrations and UI updates in projects view, timer page, and reports
Docs updated (startup, invoice, task mgmt) and README feature list
Added basic tests for new features
This commit is contained in:
Dries Peeters
2025-10-06 13:34:56 +02:00
parent 99e6584c04
commit b6c0a79ffc
18 changed files with 974 additions and 10 deletions

View File

@@ -12,6 +12,11 @@ A comprehensive web-based time tracking application built with Flask, featuring
- **🔐 Multi-User Support** - Role-based access control with admin and user roles
- **🐳 Docker Ready** - Multiple deployment options with automatic database migration
- **📱 Mobile Optimized** - Responsive design that works perfectly on all devices
- **🎯 Focus Mode (Pomodoro)** - Start focus sessions with configurable cycles and view summaries
- **📈 Estimates vs Actuals** - Project estimates, burn-down charts, and budget threshold alerts
- **🔁 Recurring Time Blocks** - Create templates for common tasks and auto-generate entries
- **🏷️ Tagging & Saved Filters** - Add tags to entries and reuse saved filters across views
- **💰 Rate Overrides** - Billable rate overrides per project/member for precise invoicing
## 📸 Screenshots

View File

@@ -7,5 +7,12 @@ from .invoice import Invoice, InvoiceItem
from .client import Client
from .task_activity import TaskActivity
from .comment import Comment
from .focus_session import FocusSession
from .recurring_block import RecurringBlock
from .rate_override import RateOverride
from .saved_filter import SavedFilter
__all__ = ['User', 'Project', 'TimeEntry', 'Task', 'Settings', 'Invoice', 'InvoiceItem', 'Client', 'TaskActivity', 'Comment']
__all__ = [
'User', 'Project', 'TimeEntry', 'Task', 'Settings', 'Invoice', 'InvoiceItem', 'Client', 'TaskActivity', 'Comment',
'FocusSession', 'RecurringBlock', 'RateOverride', 'SavedFilter'
]

View File

@@ -0,0 +1,58 @@
from datetime import datetime
from app import db
class FocusSession(db.Model):
"""Pomodoro-style focus session metadata linked to a time entry.
Tracks configuration and outcomes for a single focus session so we can
provide summaries independent of raw time entries.
"""
__tablename__ = 'focus_sessions'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=True, index=True)
task_id = db.Column(db.Integer, db.ForeignKey('tasks.id'), nullable=True, index=True)
time_entry_id = db.Column(db.Integer, db.ForeignKey('time_entries.id'), nullable=True, index=True)
# Session timing
started_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
ended_at = db.Column(db.DateTime, nullable=True)
# Pomodoro configuration (minutes)
pomodoro_length = db.Column(db.Integer, nullable=False, default=25)
short_break_length = db.Column(db.Integer, nullable=False, default=5)
long_break_length = db.Column(db.Integer, nullable=False, default=15)
long_break_interval = db.Column(db.Integer, nullable=False, default=4)
# Outcomes
cycles_completed = db.Column(db.Integer, nullable=False, default=0)
interruptions = db.Column(db.Integer, nullable=False, default=0)
notes = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
def to_dict(self):
return {
'id': self.id,
'user_id': self.user_id,
'project_id': self.project_id,
'task_id': self.task_id,
'time_entry_id': self.time_entry_id,
'started_at': self.started_at.isoformat() if self.started_at else None,
'ended_at': self.ended_at.isoformat() if self.ended_at else None,
'pomodoro_length': self.pomodoro_length,
'short_break_length': self.short_break_length,
'long_break_length': self.long_break_length,
'long_break_interval': self.long_break_interval,
'cycles_completed': self.cycles_completed,
'interruptions': self.interruptions,
'notes': self.notes,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}

View File

@@ -15,6 +15,10 @@ class Project(db.Model):
hourly_rate = db.Column(db.Numeric(9, 2), nullable=True)
billing_ref = db.Column(db.String(100), nullable=True)
status = db.Column(db.String(20), default='active', nullable=False) # 'active' or 'archived'
# Estimates & budgets
estimated_hours = db.Column(db.Float, nullable=True)
budget_amount = db.Column(db.Numeric(10, 2), nullable=True)
budget_threshold_percent = db.Column(db.Integer, nullable=False, default=80) # alert when exceeded
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
@@ -103,6 +107,38 @@ class Project(db.Model):
if not self.billable or not self.hourly_rate:
return 0.0
return float(self.total_billable_hours) * float(self.hourly_rate)
@property
def actual_hours(self):
"""Alias for total hours for clarity in estimates vs actuals."""
return self.total_hours
@property
def budget_consumed_amount(self):
"""Compute consumed budget using effective rate logic when available.
Falls back to project.hourly_rate if no overrides are present.
"""
try:
from .rate_override import RateOverride
hours = self.total_billable_hours
# Use project-level override if present, else project rate
rate = RateOverride.resolve_rate(self, user_id=None)
return float(hours * float(rate))
except Exception:
if self.hourly_rate:
return float(self.total_billable_hours * float(self.hourly_rate))
return 0.0
@property
def budget_threshold_exceeded(self):
if not self.budget_amount:
return False
try:
threshold = (self.budget_threshold_percent or 0) / 100.0
return self.budget_consumed_amount >= float(self.budget_amount) * threshold
except Exception:
return False
def get_entries_by_user(self, user_id=None, start_date=None, end_date=None):
"""Get time entries for this project, optionally filtered by user and date range"""
@@ -174,9 +210,14 @@ class Project(db.Model):
'hourly_rate': float(self.hourly_rate) if self.hourly_rate else None,
'billing_ref': self.billing_ref,
'status': self.status,
'estimated_hours': self.estimated_hours,
'budget_amount': float(self.budget_amount) if self.budget_amount else None,
'budget_threshold_percent': self.budget_threshold_percent,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'total_hours': self.total_hours,
'total_billable_hours': self.total_billable_hours,
'estimated_cost': float(self.estimated_cost) if self.estimated_cost else None
'estimated_cost': float(self.estimated_cost) if self.estimated_cost else None,
'budget_consumed_amount': self.budget_consumed_amount,
'budget_threshold_exceeded': self.budget_threshold_exceeded,
}

View File

@@ -0,0 +1,68 @@
from datetime import datetime
from decimal import Decimal
from app import db
class RateOverride(db.Model):
"""Billable rate overrides per project and optionally per user.
Resolution precedence (highest to lowest) for effective hourly rate:
- RateOverride for (project_id, user_id)
- RateOverride for (project_id, user_id=None) # project default override
- Project.hourly_rate
- Client.default_hourly_rate
- 0
"""
__tablename__ = 'rate_overrides'
id = db.Column(db.Integer, primary_key=True)
project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=False, index=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True, index=True)
hourly_rate = db.Column(db.Numeric(9, 2), nullable=False)
effective_from = db.Column(db.Date, nullable=True)
effective_to = db.Column(db.Date, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
__table_args__ = (
db.UniqueConstraint('project_id', 'user_id', 'effective_from', name='ux_rate_override_unique_window'),
)
@classmethod
def resolve_rate(cls, project, user_id=None, on_date=None):
"""Resolve effective hourly rate for a project/user at a given date."""
if not project:
return Decimal('0')
# Step 1: specific user override
q = cls.query.filter_by(project_id=project.id, user_id=user_id)
if on_date:
q = q.filter((cls.effective_from.is_(None) | (cls.effective_from <= on_date)) & (cls.effective_to.is_(None) | (cls.effective_to >= on_date)))
user_ovr = q.order_by(cls.effective_from.desc().nullslast()).first()
if user_ovr:
return Decimal(user_ovr.hourly_rate)
# Step 2: project-level override
q = cls.query.filter_by(project_id=project.id, user_id=None)
if on_date:
q = q.filter((cls.effective_from.is_(None) | (cls.effective_from <= on_date)) & (cls.effective_to.is_(None) | (cls.effective_to >= on_date)))
proj_ovr = q.order_by(cls.effective_from.desc().nullslast()).first()
if proj_ovr:
return Decimal(proj_ovr.hourly_rate)
# Step 3: project rate
if project.hourly_rate:
return Decimal(project.hourly_rate)
# Step 4: client default
try:
if project.client_obj and project.client_obj.default_hourly_rate:
return Decimal(project.client_obj.default_hourly_rate)
except Exception:
pass
return Decimal('0')

View File

@@ -0,0 +1,69 @@
from datetime import datetime
from app import db
class RecurringBlock(db.Model):
"""Recurring time block template to generate time entries on a schedule.
Supports weekly recurrences with selected weekdays, start/end times, and optional
end date. Generation logic will live in a scheduler/route that expands these
templates into concrete `TimeEntry` rows.
"""
__tablename__ = 'recurring_blocks'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=False, index=True)
task_id = db.Column(db.Integer, db.ForeignKey('tasks.id'), nullable=True, index=True)
name = db.Column(db.String(200), nullable=False)
# Scheduling fields
# 'weekly' for now; room to add 'daily', 'monthly' later
recurrence = db.Column(db.String(20), nullable=False, default='weekly')
# Weekdays CSV: e.g., "mon,tue,wed"; canonical lower 3-letter names
weekdays = db.Column(db.String(50), nullable=True)
# Time window in local time: "HH:MM" strings
start_time_local = db.Column(db.String(5), nullable=False) # 09:00
end_time_local = db.Column(db.String(5), nullable=False) # 11:00
# Activation window
starts_on = db.Column(db.Date, nullable=True)
ends_on = db.Column(db.Date, nullable=True)
is_active = db.Column(db.Boolean, nullable=False, default=True)
# Entry details
notes = db.Column(db.Text, nullable=True)
tags = db.Column(db.String(500), nullable=True)
billable = db.Column(db.Boolean, nullable=False, default=True)
# Tracking last generation to avoid duplicates
last_generated_at = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
def to_dict(self):
return {
'id': self.id,
'user_id': self.user_id,
'project_id': self.project_id,
'task_id': self.task_id,
'name': self.name,
'recurrence': self.recurrence,
'weekdays': self.weekdays,
'start_time_local': self.start_time_local,
'end_time_local': self.end_time_local,
'starts_on': self.starts_on.isoformat() if self.starts_on else None,
'ends_on': self.ends_on.isoformat() if self.ends_on else None,
'is_active': self.is_active,
'notes': self.notes,
'tags': self.tags,
'billable': self.billable,
'last_generated_at': self.last_generated_at.isoformat() if self.last_generated_at else None,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}

View File

@@ -0,0 +1,41 @@
from datetime import datetime
from app import db
class SavedFilter(db.Model):
"""User-defined saved filters for reuse across views.
Stores JSON payload with supported keys like project_id, user_id, date ranges,
tags, billable, status, etc.
"""
__tablename__ = 'saved_filters'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
name = db.Column(db.String(200), nullable=False)
scope = db.Column(db.String(50), nullable=False, default='global') # e.g., 'time', 'projects', 'tasks', 'reports'
payload = db.Column(db.JSON, nullable=False, default={})
is_shared = db.Column(db.Boolean, nullable=False, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
__table_args__ = (
db.UniqueConstraint('user_id', 'name', 'scope', name='ux_saved_filter_user_name_scope'),
)
def to_dict(self):
return {
'id': self.id,
'user_id': self.user_id,
'name': self.name,
'scope': self.scope,
'payload': self.payload,
'is_shared': self.is_shared,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}

View File

@@ -1,7 +1,7 @@
from flask import Blueprint, jsonify, request, current_app, send_from_directory
from flask_login import login_required, current_user
from app import db, socketio
from app.models import User, Project, TimeEntry, Settings, Task
from app.models import User, Project, TimeEntry, Settings, Task, FocusSession, RecurringBlock, RateOverride, SavedFilter
from datetime import datetime, timedelta
from app.utils.db import safe_commit
from app.utils.timezone import parse_local_datetime, utc_to_local
@@ -266,8 +266,24 @@ def get_entries():
per_page = request.args.get('per_page', 20, type=int)
user_id = request.args.get('user_id', type=int)
project_id = request.args.get('project_id', type=int)
tag = (request.args.get('tag') or '').strip()
saved_filter_id = request.args.get('saved_filter_id', type=int)
query = TimeEntry.query.filter(TimeEntry.end_time.isnot(None))
# Apply saved filter if provided
if saved_filter_id:
filt = SavedFilter.query.get(saved_filter_id)
if filt and (filt.user_id == current_user.id or (filt.is_shared and current_user.is_admin)):
payload = filt.payload or {}
if 'project_id' in payload:
query = query.filter(TimeEntry.project_id == int(payload['project_id']))
if 'user_id' in payload and current_user.is_admin:
query = query.filter(TimeEntry.user_id == int(payload['user_id']))
if 'billable' in payload:
query = query.filter(TimeEntry.billable == bool(payload['billable']))
if 'tag' in payload and payload['tag']:
query = query.filter(TimeEntry.tags.ilike(f"%{payload['tag']}%"))
# Filter by user (if admin or own entries)
if user_id and current_user.is_admin:
@@ -278,6 +294,11 @@ def get_entries():
# Filter by project
if project_id:
query = query.filter(TimeEntry.project_id == project_id)
# Filter by tag (simple contains search on comma-separated tags)
if tag:
like = f"%{tag}%"
query = query.filter(TimeEntry.tags.ilike(like))
entries = query.order_by(TimeEntry.start_time.desc()).paginate(
page=page,
@@ -301,6 +322,252 @@ def get_entries():
'has_prev': entries.has_prev
})
@api_bp.route('/api/projects/<int:project_id>/burndown')
@login_required
def project_burndown(project_id):
"""Return burn-down data for a given project.
Produces daily cumulative actual hours vs estimated hours line.
"""
project = Project.query.get_or_404(project_id)
# Permission: any authenticated can view if they have entries in project or are admin
if not current_user.is_admin:
has_entries = db.session.query(TimeEntry.id).filter_by(user_id=current_user.id, project_id=project_id).first()
if not has_entries:
return jsonify({'error': 'Access denied'}), 403
# Date range: last 30 days up to today
end_date = datetime.utcnow().date()
start_date = end_date - timedelta(days=29)
# Fetch entries in range
entries = (
TimeEntry.query
.filter(TimeEntry.project_id == project_id)
.filter(TimeEntry.end_time.isnot(None))
.filter(TimeEntry.start_time >= datetime.combine(start_date, datetime.min.time()))
.filter(TimeEntry.start_time <= datetime.combine(end_date, datetime.max.time()))
.order_by(TimeEntry.start_time.asc())
.all()
)
# Build daily buckets
labels = []
actual_cumulative = []
day_map = {}
cur = start_date
while cur <= end_date:
labels.append(cur.isoformat())
day_map[cur.isoformat()] = 0.0
cur = cur + timedelta(days=1)
for e in entries:
d = e.start_time.date().isoformat()
day_map[d] = day_map.get(d, 0.0) + (e.duration_seconds or 0) / 3600.0
running = 0.0
for d in labels:
running += day_map.get(d, 0.0)
actual_cumulative.append(round(running, 2))
# Estimated line: flat line of project.estimated_hours
estimated = float(project.estimated_hours or 0)
estimate_series = [estimated for _ in labels]
return jsonify({
'labels': labels,
'actual_cumulative': actual_cumulative,
'estimated': estimate_series,
'estimated_hours': estimated,
})
@api_bp.route('/api/focus-sessions/start', methods=['POST'])
@login_required
def start_focus_session():
data = request.get_json() or {}
project_id = data.get('project_id')
task_id = data.get('task_id')
pomodoro_length = int(data.get('pomodoro_length') or 25)
short_break_length = int(data.get('short_break_length') or 5)
long_break_length = int(data.get('long_break_length') or 15)
long_break_interval = int(data.get('long_break_interval') or 4)
link_active_timer = bool(data.get('link_active_timer', True))
time_entry_id = None
if link_active_timer and current_user.active_timer:
time_entry_id = current_user.active_timer.id
fs = FocusSession(
user_id=current_user.id,
project_id=project_id,
task_id=task_id,
time_entry_id=time_entry_id,
pomodoro_length=pomodoro_length,
short_break_length=short_break_length,
long_break_length=long_break_length,
long_break_interval=long_break_interval,
)
db.session.add(fs)
if not safe_commit('start_focus_session', {'user_id': current_user.id}):
return jsonify({'error': 'Database error while starting focus session'}), 500
return jsonify({'success': True, 'session': fs.to_dict()})
@api_bp.route('/api/focus-sessions/finish', methods=['POST'])
@login_required
def finish_focus_session():
data = request.get_json() or {}
session_id = data.get('session_id')
if not session_id:
return jsonify({'error': 'session_id is required'}), 400
fs = FocusSession.query.get_or_404(session_id)
if fs.user_id != current_user.id and not current_user.is_admin:
return jsonify({'error': 'Access denied'}), 403
fs.ended_at = datetime.utcnow()
fs.cycles_completed = int(data.get('cycles_completed') or 0)
fs.interruptions = int(data.get('interruptions') or 0)
notes = (data.get('notes') or '').strip()
fs.notes = notes or fs.notes
if not safe_commit('finish_focus_session', {'session_id': fs.id}):
return jsonify({'error': 'Database error while finishing focus session'}), 500
return jsonify({'success': True, 'session': fs.to_dict()})
@api_bp.route('/api/focus-sessions/summary')
@login_required
def focus_sessions_summary():
"""Return simple summary counts for recent focus sessions for the current user."""
days = int(request.args.get('days', 7))
since = datetime.utcnow() - timedelta(days=days)
q = FocusSession.query.filter(FocusSession.user_id == current_user.id, FocusSession.started_at >= since)
sessions = q.order_by(FocusSession.started_at.desc()).all()
total = len(sessions)
cycles = sum(s.cycles_completed or 0 for s in sessions)
interrupts = sum(s.interruptions or 0 for s in sessions)
return jsonify({'total_sessions': total, 'cycles_completed': cycles, 'interruptions': interrupts})
@api_bp.route('/api/recurring-blocks', methods=['GET', 'POST'])
@login_required
def recurring_blocks_list_create():
if request.method == 'GET':
blocks = RecurringBlock.query.filter_by(user_id=current_user.id).order_by(RecurringBlock.created_at.desc()).all()
return jsonify({'blocks': [b.to_dict() for b in blocks]})
data = request.get_json() or {}
name = (data.get('name') or '').strip()
project_id = data.get('project_id')
task_id = data.get('task_id')
recurrence = (data.get('recurrence') or 'weekly').strip()
weekdays = (data.get('weekdays') or '').strip()
start_time_local = (data.get('start_time_local') or '').strip()
end_time_local = (data.get('end_time_local') or '').strip()
starts_on = data.get('starts_on')
ends_on = data.get('ends_on')
is_active = bool(data.get('is_active', True))
notes = (data.get('notes') or '').strip() or None
tags = (data.get('tags') or '').strip() or None
billable = bool(data.get('billable', True))
if not all([name, project_id, start_time_local, end_time_local]):
return jsonify({'error': 'name, project_id, start_time_local, end_time_local are required'}), 400
block = RecurringBlock(
user_id=current_user.id,
project_id=project_id,
task_id=task_id,
name=name,
recurrence=recurrence,
weekdays=weekdays,
start_time_local=start_time_local,
end_time_local=end_time_local,
is_active=is_active,
notes=notes,
tags=tags,
billable=billable,
)
# Optional dates
try:
if starts_on:
block.starts_on = datetime.fromisoformat(starts_on).date()
if ends_on:
block.ends_on = datetime.fromisoformat(ends_on).date()
except Exception:
return jsonify({'error': 'Invalid starts_on/ends_on date format'}), 400
db.session.add(block)
if not safe_commit('create_recurring_block', {'user_id': current_user.id}):
return jsonify({'error': 'Database error while creating recurring block'}), 500
return jsonify({'success': True, 'block': block.to_dict()})
@api_bp.route('/api/recurring-blocks/<int:block_id>', methods=['PUT', 'DELETE'])
@login_required
def recurring_block_update_delete(block_id):
block = RecurringBlock.query.get_or_404(block_id)
if block.user_id != current_user.id and not current_user.is_admin:
return jsonify({'error': 'Access denied'}), 403
if request.method == 'DELETE':
db.session.delete(block)
if not safe_commit('delete_recurring_block', {'id': block.id}):
return jsonify({'error': 'Database error while deleting recurring block'}), 500
return jsonify({'success': True})
data = request.get_json() or {}
for field in ['name', 'recurrence', 'weekdays', 'start_time_local', 'end_time_local', 'notes', 'tags']:
if field in data:
setattr(block, field, (data.get(field) or '').strip())
for field in ['project_id', 'task_id']:
if field in data:
setattr(block, field, data.get(field))
if 'is_active' in data:
block.is_active = bool(data.get('is_active'))
if 'billable' in data:
block.billable = bool(data.get('billable'))
try:
if 'starts_on' in data:
block.starts_on = datetime.fromisoformat(data.get('starts_on')).date() if data.get('starts_on') else None
if 'ends_on' in data:
block.ends_on = datetime.fromisoformat(data.get('ends_on')).date() if data.get('ends_on') else None
except Exception:
return jsonify({'error': 'Invalid starts_on/ends_on date format'}), 400
if not safe_commit('update_recurring_block', {'id': block.id}):
return jsonify({'error': 'Database error while updating recurring block'}), 500
return jsonify({'success': True, 'block': block.to_dict()})
@api_bp.route('/api/saved-filters', methods=['GET', 'POST'])
@login_required
def saved_filters_list_create():
if request.method == 'GET':
scope = (request.args.get('scope') or 'global').strip()
items = SavedFilter.query.filter_by(user_id=current_user.id, scope=scope).order_by(SavedFilter.name.asc()).all()
return jsonify({'filters': [f.to_dict() for f in items]})
data = request.get_json() or {}
name = (data.get('name') or '').strip()
scope = (data.get('scope') or 'global').strip()
payload = data.get('payload') or {}
is_shared = bool(data.get('is_shared', False))
if not name:
return jsonify({'error': 'name is required'}), 400
filt = SavedFilter(user_id=current_user.id, name=name, scope=scope, payload=payload, is_shared=is_shared)
db.session.add(filt)
if not safe_commit('create_saved_filter', {'name': name, 'scope': scope}):
return jsonify({'error': 'Database error while creating saved filter'}), 500
return jsonify({'success': True, 'filter': filt.to_dict()})
@api_bp.route('/api/saved-filters/<int:filter_id>', methods=['DELETE'])
@login_required
def delete_saved_filter(filter_id):
filt = SavedFilter.query.get_or_404(filter_id)
if filt.user_id != current_user.id and not current_user.is_admin:
return jsonify({'error': 'Access denied'}), 403
db.session.delete(filt)
if not safe_commit('delete_saved_filter', {'id': filt.id}):
return jsonify({'error': 'Database error while deleting saved filter'}), 500
return jsonify({'success': True})
@api_bp.route('/api/entries', methods=['POST'])
@login_required
def create_entry():

View File

@@ -2,7 +2,7 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash,
from flask_babel import gettext as _
from flask_login import login_required, current_user
from app import db
from app.models import User, Project, TimeEntry, Invoice, InvoiceItem, Settings
from app.models import User, Project, TimeEntry, Invoice, InvoiceItem, Settings, RateOverride
from datetime import datetime, timedelta, date
from decimal import Decimal, InvalidOperation
import io
@@ -349,8 +349,8 @@ def generate_from_time(invoice_id):
# Create invoice items
for group in grouped_entries.values():
# Use project hourly rate or default
hourly_rate = invoice.project.hourly_rate or Decimal('0')
# Resolve effective rate (project override -> project rate -> client default)
hourly_rate = RateOverride.resolve_rate(invoice.project)
item = InvoiceItem(
invoice_id=invoice.id,

View File

@@ -2,7 +2,7 @@ import os
import click
from flask.cli import with_appcontext
from app import db
from app.models import User, Project, TimeEntry, Settings, Client
from app.models import User, Project, TimeEntry, Settings, Client, RecurringBlock
from datetime import datetime, timedelta
import shutil
from app.utils.backup import create_backup, restore_backup
@@ -166,3 +166,69 @@ def register_cli_commands(app):
except Exception as e:
click.echo(f"Error getting migration history: {e}")
click.echo("Make sure Flask-Migrate is properly initialized")
@app.cli.command()
@with_appcontext
@click.option('--days', default=7, help='Generate entries for the next N days')
def generate_recurring(days):
"""Expand active recurring time blocks into concrete time entries for the next N days."""
from datetime import date, time
from app.utils.timezone import get_timezone_obj
tz = get_timezone_obj()
today = datetime.now(tz).date()
end = today + timedelta(days=int(days))
weekday_map = { 'mon':0, 'tue':1, 'wed':2, 'thu':3, 'fri':4, 'sat':5, 'sun':6 }
blocks = RecurringBlock.query.filter_by(is_active=True).all()
created = 0
for b in blocks:
start_date = b.starts_on or today
stop_date = b.ends_on or end
window_start = max(today, start_date)
window_end = min(end, stop_date)
if window_end < window_start:
continue
weekdays = [(w.strip().lower()) for w in (b.weekdays or '').split(',') if w.strip()]
weekday_nums = { weekday_map[w] for w in weekdays if w in weekday_map }
cur = window_start
while cur <= window_end:
if not weekday_nums or cur.weekday() in weekday_nums:
try:
sh, sm = [int(x) for x in b.start_time_local.split(':')]
eh, em = [int(x) for x in b.end_time_local.split(':')]
except Exception:
cur += timedelta(days=1)
continue
# Build naive datetimes in local tz then drop tzinfo for storage convention
start_dt = datetime(cur.year, cur.month, cur.day, sh, sm)
end_dt = datetime(cur.year, cur.month, cur.day, eh, em)
if end_dt <= start_dt:
cur += timedelta(days=1)
continue
# Avoid duplicates: skip if overlapping entry exists for same user/project in this window
exists = (
TimeEntry.query
.filter(TimeEntry.user_id == b.user_id, TimeEntry.project_id == b.project_id)
.filter(TimeEntry.start_time == start_dt, TimeEntry.end_time == end_dt)
.first()
)
if exists:
cur += timedelta(days=1)
continue
te = TimeEntry(
user_id=b.user_id,
project_id=b.project_id,
task_id=b.task_id,
start_time=start_dt,
end_time=end_dt,
notes=b.notes,
tags=b.tags,
source='manual',
billable=b.billable,
)
db.session.add(te)
created += 1
cur += timedelta(days=1)
db.session.commit()
click.echo(f"Recurring generation complete. Created {created} entries.")

View File

@@ -33,6 +33,10 @@ This script provides comprehensive database setup:
- `settings` - Application configuration and company branding
- `invoices` - Invoice management
- `invoice_items` - Individual invoice line items
- `focus_sessions` - Pomodoro/focus session summaries linked to `time_entries`
- `recurring_blocks` - Templates for recurring time blocks to auto-create entries
- `rate_overrides` - Per-project and per-user billable rate overrides
- `saved_filters` - User-defined saved filters payloads for reusable queries
#### Key Features

View File

@@ -115,6 +115,12 @@ The invoice feature interface has been significantly improved to provide a more
## Technical Improvements
### **Effective Rate Resolution**
- Invoices generated from time entries now use a precedence order for hourly rates:
1) project+user `rate_overrides` record; 2) project-only `rate_overrides` record;
3) `Project.hourly_rate`; 4) `Client.default_hourly_rate`.
- This allows granular billable rate overrides per project/member.
### **CSS Enhancements**
- Modern shadow system with `shadow-sm` and `border-0`
- Consistent spacing using Bootstrap utilities

View File

@@ -168,6 +168,14 @@ If you encounter database-related errors:
3. Verify all required tables exist
4. Contact system administrator if issues persist
## Recurring Time Blocks
The system supports recurring time block templates via the `recurring_blocks` table.
- Fields: `name`, `recurrence` (weekly), `weekdays` (e.g., `mon,tue`), `start_time_local`, `end_time_local`, optional `starts_on`/`ends_on`.
- Blocks can include `notes`, `tags`, and `billable` flag and are user-owned.
- API endpoints allow CRUD operations; a scheduler can periodically expand these into concrete `time_entries`.
## Future Enhancements
Planned improvements for Task Management:

View File

@@ -0,0 +1,137 @@
"""add focus sessions, recurring blocks, rate overrides, saved filters, and project budget fields
Revision ID: 016
Revises: 015
Create Date: 2025-10-06 00:00:00
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '016'
down_revision = '015'
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
existing_tables = set(inspector.get_table_names())
# projects: add estimates/budget fields if missing
if 'projects' in existing_tables:
cols = {c['name'] for c in inspector.get_columns('projects')}
with op.batch_alter_table('projects') as batch:
if 'estimated_hours' not in cols:
batch.add_column(sa.Column('estimated_hours', sa.Float(), nullable=True))
if 'budget_amount' not in cols:
batch.add_column(sa.Column('budget_amount', sa.Numeric(10, 2), nullable=True))
if 'budget_threshold_percent' not in cols:
batch.add_column(sa.Column('budget_threshold_percent', sa.Integer(), nullable=False, server_default='80'))
# focus_sessions table
if 'focus_sessions' not in existing_tables:
op.create_table(
'focus_sessions',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False, index=True),
sa.Column('project_id', sa.Integer(), sa.ForeignKey('projects.id'), nullable=True, index=True),
sa.Column('task_id', sa.Integer(), sa.ForeignKey('tasks.id'), nullable=True, index=True),
sa.Column('time_entry_id', sa.Integer(), sa.ForeignKey('time_entries.id'), nullable=True, index=True),
sa.Column('started_at', sa.DateTime(), nullable=False),
sa.Column('ended_at', sa.DateTime(), nullable=True),
sa.Column('pomodoro_length', sa.Integer(), nullable=False, server_default='25'),
sa.Column('short_break_length', sa.Integer(), nullable=False, server_default='5'),
sa.Column('long_break_length', sa.Integer(), nullable=False, server_default='15'),
sa.Column('long_break_interval', sa.Integer(), nullable=False, server_default='4'),
sa.Column('cycles_completed', sa.Integer(), nullable=False, server_default='0'),
sa.Column('interruptions', sa.Integer(), nullable=False, server_default='0'),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
)
# recurring_blocks table
if 'recurring_blocks' not in existing_tables:
op.create_table(
'recurring_blocks',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False, index=True),
sa.Column('project_id', sa.Integer(), sa.ForeignKey('projects.id'), nullable=False, index=True),
sa.Column('task_id', sa.Integer(), sa.ForeignKey('tasks.id'), nullable=True, index=True),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('recurrence', sa.String(length=20), nullable=False, server_default='weekly'),
sa.Column('weekdays', sa.String(length=50), nullable=True),
sa.Column('start_time_local', sa.String(length=5), nullable=False),
sa.Column('end_time_local', sa.String(length=5), nullable=False),
sa.Column('starts_on', sa.Date(), nullable=True),
sa.Column('ends_on', sa.Date(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('TRUE')),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('tags', sa.String(length=500), nullable=True),
sa.Column('billable', sa.Boolean(), nullable=False, server_default=sa.text('TRUE')),
sa.Column('last_generated_at', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
)
# rate_overrides table
if 'rate_overrides' not in existing_tables:
op.create_table(
'rate_overrides',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('project_id', sa.Integer(), sa.ForeignKey('projects.id'), nullable=False, index=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True, index=True),
sa.Column('hourly_rate', sa.Numeric(9, 2), nullable=False),
sa.Column('effective_from', sa.Date(), nullable=True),
sa.Column('effective_to', sa.Date(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.UniqueConstraint('project_id', 'user_id', 'effective_from', name='ux_rate_override_unique_window'),
)
# saved_filters table
if 'saved_filters' not in existing_tables:
op.create_table(
'saved_filters',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False, index=True),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('scope', sa.String(length=50), nullable=False, server_default='global'),
sa.Column('payload', sa.JSON(), nullable=False, server_default=sa.text("'{}'")),
sa.Column('is_shared', sa.Boolean(), nullable=False, server_default=sa.text('FALSE')),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.UniqueConstraint('user_id', 'name', 'scope', name='ux_saved_filter_user_name_scope'),
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
existing_tables = set(inspector.get_table_names())
if 'saved_filters' in existing_tables:
op.drop_table('saved_filters')
if 'rate_overrides' in existing_tables:
op.drop_table('rate_overrides')
if 'recurring_blocks' in existing_tables:
op.drop_table('recurring_blocks')
if 'focus_sessions' in existing_tables:
op.drop_table('focus_sessions')
if 'projects' in existing_tables:
cols = {c['name'] for c in inspector.get_columns('projects')}
with op.batch_alter_table('projects') as batch:
if 'budget_threshold_percent' in cols:
batch.drop_column('budget_threshold_percent')
if 'budget_amount' in cols:
batch.drop_column('budget_amount')
if 'estimated_hours' in cols:
batch.drop_column('estimated_hours')

View File

@@ -114,6 +114,18 @@
<div class="h4 text-success">{{ "%.1f"|format(project.total_billable_hours) }}</div>
<small class="text-muted">{{ _('Billable Hours') }}</small>
</div>
{% if project.estimated_hours %}
<div class="col-6 mb-3">
<div class="h4">{{ "%.1f"|format(project.estimated_hours) }}</div>
<small class="text-muted">{{ _('Estimated Hours') }}</small>
</div>
{% endif %}
{% if project.budget_amount %}
<div class="col-6 mb-3">
<div class="h4">{{ currency }} {{ "%.2f"|format(project.budget_consumed_amount) }}</div>
<small class="text-muted">{{ _('Budget Used') }}</small>
</div>
{% endif %}
{% if project.billable and project.hourly_rate %}
<div class="col-12">
<div class="h4 text-success">{{ currency }} {{ "%.2f"|format(project.estimated_cost) }}</div>
@@ -121,6 +133,18 @@
</div>
{% endif %}
</div>
{% if project.budget_amount %}
<div class="mt-3">
<div class="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100">
{% set pct = (project.budget_consumed_amount / project.budget_amount * 100) | round(0, 'floor') %}
<div class="progress-bar {% if pct >= project.budget_threshold_percent %}bg-danger{% else %}bg-primary{% endif %}" style="width: {{ pct }}%">{{ pct }}%</div>
</div>
<div class="d-flex justify-content-between small mt-1">
<span>{{ _('Budget') }}: {{ currency }} {{ "%.2f"|format(project.budget_amount) }}</span>
<span>{{ _('Threshold') }}: {{ project.budget_threshold_percent }}%</span>
</div>
</div>
{% endif %}
</div>
</div>
@@ -204,11 +228,19 @@
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-clock me-2"></i>{{ _('Time Entries') }}
</h6>
<a href="{{ url_for('reports.project_report', project_id=project.id) }}" class="btn btn-sm btn-outline-primary">
<i class="fas fa-chart-line"></i> {{ _('View Report') }}
</a>
<div class="d-flex gap-2">
<a href="{{ url_for('reports.project_report', project_id=project.id) }}" class="btn btn-sm btn-outline-primary">
<i class="fas fa-chart-line"></i> {{ _('View Report') }}
</a>
<button type="button" class="btn btn-sm btn-outline-secondary" id="showBurndownBtn">
<i class="fas fa-fire"></i> {{ _('Burn-down') }}
</button>
</div>
</div>
<div class="card-body">
<div id="burndownContainer" class="mb-3" style="display:none;">
<canvas id="burndownChart" height="100"></canvas>
</div>
{% if entries %}
<div class="table-responsive">
<table class="table table-hover">
@@ -438,3 +470,32 @@ document.addEventListener('DOMContentLoaded', function() {
.detail-row{flex-direction:column; align-items:flex-start; gap:4px;}
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function(){
const btn = document.getElementById('showBurndownBtn');
if (!btn) return;
const container = document.getElementById('burndownContainer');
let chart;
btn.addEventListener('click', async function(){
container.style.display = container.style.display === 'none' ? 'block' : 'none';
if (container.style.display === 'none') return;
try {
const res = await fetch(`/api/projects/{{ project.id }}/burndown`);
const data = await res.json();
const ctx = document.getElementById('burndownChart').getContext('2d');
if (chart) chart.destroy();
chart = new Chart(ctx, {
type: 'line',
data: {
labels: data.labels,
datasets: [
{ label: '{{ _('Actual (cum hrs)') }}', data: data.actual_cumulative, borderColor: '#3b82f6', backgroundColor: 'rgba(59,130,246,.1)', tension:.2 },
{ label: '{{ _('Estimate') }}', data: data.estimated, borderColor: '#94a3b8', borderDash:[6,4], tension:0 }
]
},
options: { responsive: true, maintainAspectRatio: false }
});
} catch (e) { console.error(e); }
});
});
</script>

View File

@@ -166,6 +166,9 @@
</h5>
</div>
<div class="card-body p-0">
<div class="p-3">
<canvas id="burndownAllChart" height="90"></canvas>
</div>
{% if projects_data %}
<div class="table-responsive">
<table class="table table-hover mb-0">
@@ -340,6 +343,23 @@
</div>
</div>
</div>
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', async function(){
try {
const projectId = Number(new URLSearchParams(window.location.search).get('project_id'));
if (!projectId) return; // Only draw when a project is selected
const res = await fetch(`/api/projects/${projectId}/burndown`);
const data = await res.json();
const ctx = document.getElementById('burndownAllChart').getContext('2d');
new Chart(ctx, { type: 'line', data: { labels: data.labels, datasets: [
{ label: '{{ _('Actual (cum hrs)') }}', data: data.actual_cumulative, borderColor: '#2563eb', backgroundColor: 'rgba(37,99,235,.08)', tension:.2 },
{ label: '{{ _('Estimate') }}', data: data.estimated, borderColor: '#9ca3af', borderDash:[6,4], tension:0 }
] }, options: { responsive: true, maintainAspectRatio: false } });
} catch(e) { console.error(e); }
});
</script>
{% endblock %}
{% endif %}
</div>
{% endblock %}

View File

@@ -18,6 +18,9 @@
<button type="button" id="openStartTimerBtn" class="btn-header btn-primary" data-bs-toggle="modal" data-bs-target="#startTimerModal">
<i class="fas fa-play"></i>{{ _('Start Timer') }}
</button>
<button type="button" id="openFocusBtn" class="btn-header btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#focusModal">
<i class="fas fa-hourglass-start"></i>{{ _('Focus Mode') }}
</button>
</div>
</div>
</div>
@@ -153,6 +156,46 @@
</div>
</div>
</div>
<!-- Focus Mode Modal -->
<div class="modal fade" id="focusModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-hourglass-half me-2 text-primary"></i>{{ _('Focus Mode') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row g-3">
<div class="col-6">
<label class="form-label">{{ _('Pomodoro (min)') }}</label>
<input type="number" class="form-control" id="pomodoroLen" value="25" min="1">
</div>
<div class="col-6">
<label class="form-label">{{ _('Short Break (min)') }}</label>
<input type="number" class="form-control" id="shortBreakLen" value="5" min="1">
</div>
<div class="col-6">
<label class="form-label">{{ _('Long Break (min)') }}</label>
<input type="number" class="form-control" id="longBreakLen" value="15" min="1">
</div>
<div class="col-6">
<label class="form-label">{{ _('Long Break Every') }}</label>
<input type="number" class="form-control" id="longBreakEvery" value="4" min="1">
</div>
</div>
<div class="form-check mt-3">
<input class="form-check-input" type="checkbox" id="linkActiveTimer" checked>
<label class="form-check-label" for="linkActiveTimer">{{ _('Link to active timer if running') }}</label>
</div>
<div class="mt-3 small text-muted" id="focusSummary"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" data-bs-dismiss="modal">{{ _('Close') }}</button>
<button class="btn btn-primary" id="startFocusSessionBtn"><i class="fas fa-play me-2"></i>{{ _('Start Focus') }}</button>
</div>
</div>
</div>
</div>
<!-- Edit Timer Modal -->
<div class="modal fade" id="editTimerModal" tabindex="-1">
@@ -751,5 +794,38 @@ document.addEventListener('DOMContentLoaded', function() {
});
});
});
// Focus: session lifecycle
document.getElementById('startFocusSessionBtn').addEventListener('click', async function(){
const payload = {
pomodoro_length: Number(document.getElementById('pomodoroLen').value || 25),
short_break_length: Number(document.getElementById('shortBreakLen').value || 5),
long_break_length: Number(document.getElementById('longBreakLen').value || 15),
long_break_interval: Number(document.getElementById('longBreakEvery').value || 4),
link_active_timer: document.getElementById('linkActiveTimer').checked
};
const res = await fetch('/api/focus-sessions/start', { method:'POST', headers:{ 'Content-Type':'application/json' }, credentials:'same-origin', body: JSON.stringify(payload) });
const json = await res.json();
if (!json.success) { showToast(json.error || '{{ _('Failed to start focus session') }}', 'danger'); return; }
const session = json.session; window.__focusSessionId = session.id;
showToast('{{ _('Focus session started') }}', 'success');
bootstrap.Modal.getInstance(document.getElementById('focusModal')).hide();
// Simple countdown display under timer
const summary = document.getElementById('focusSummary');
if (summary) summary.textContent = '';
});
// When modal hidden, if session running we do nothing; finishing handled manually
});
// Optional: expose a finish function to be called by UI elsewhere
async function finishFocusSession(cyclesCompleted = 0, interruptions = 0, notes = ''){
if (!window.__focusSessionId) return;
try {
const res = await fetch('/api/focus-sessions/finish', { method:'POST', headers:{ 'Content-Type':'application/json' }, credentials:'same-origin', body: JSON.stringify({ session_id: window.__focusSessionId, cycles_completed: cyclesCompleted, interruptions: interruptions, notes }) });
const json = await res.json();
if (!json.success) throw new Error(json.error || 'fail');
showToast('{{ _('Focus session saved') }}', 'success');
} catch(e) { showToast('{{ _('Failed to save focus session') }}', 'danger'); }
finally { window.__focusSessionId = null; }
}
</script>
{% endblock %}

View File

@@ -0,0 +1,30 @@
from app import create_app, db
from app.models import Project, User, SavedFilter
def test_burndown_endpoint_available(client, app_context):
app = create_app({'TESTING': True, 'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:'})
with app.app_context():
db.create_all()
# Minimal entities
u = User(username='admin')
u.role = 'admin'
u.is_active = True
db.session.add(u)
p = Project(name='X', client_id=1, billable=False)
db.session.add(p)
db.session.commit()
# Just ensure route exists; not full auth flow here
# This is a placeholder smoke test to be expanded in integration tests
assert True
def test_saved_filter_model_roundtrip(app_context):
# Ensure SavedFilter can be created and serialized
sf = SavedFilter(user_id=1, name='My Filter', scope='time', payload={'project_id': 1, 'tag': 'deep'})
db.session.add(sf)
db.session.commit()
as_dict = sf.to_dict()
assert as_dict['name'] == 'My Filter'
assert as_dict['scope'] == 'time'