Merge pull request #165 from DRYTRIX/Feat-CalendarSupport

Feat calendar support
This commit is contained in:
Dries Peeters
2025-10-27 12:53:22 +01:00
committed by GitHub
15 changed files with 4171 additions and 53 deletions

View File

@@ -768,6 +768,7 @@ def create_app(config=None):
from app.routes.weekly_goals import weekly_goals_bp
from app.routes.expenses import expenses_bp
from app.routes.permissions import permissions_bp
from app.routes.calendar import calendar_bp
app.register_blueprint(auth_bp)
app.register_blueprint(main_bp)
@@ -794,6 +795,7 @@ def create_app(config=None):
app.register_blueprint(weekly_goals_bp)
app.register_blueprint(expenses_bp)
app.register_blueprint(permissions_bp)
app.register_blueprint(calendar_bp)
# Exempt API blueprints from CSRF protection (JSON API uses token authentication, not CSRF tokens)
# Only if CSRF is enabled

View File

@@ -27,6 +27,7 @@ from .weekly_time_goal import WeeklyTimeGoal
from .expense import Expense
from .permission import Permission, Role
from .api_token import ApiToken
from .calendar_event import CalendarEvent
__all__ = [
"User",
@@ -63,4 +64,5 @@ __all__ = [
"Permission",
"Role",
"ApiToken",
"CalendarEvent",
]

View File

@@ -0,0 +1,227 @@
from datetime import datetime
from app import db
from app.utils.timezone import now_in_app_timezone
class CalendarEvent(db.Model):
"""Calendar event model for scheduling meetings, appointments, and other events"""
__tablename__ = 'calendar_events'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
title = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True)
start_time = db.Column(db.DateTime, nullable=False, index=True)
end_time = db.Column(db.DateTime, nullable=False, index=True)
all_day = db.Column(db.Boolean, default=False, nullable=False)
location = db.Column(db.String(200), nullable=True)
# Event type: meeting, appointment, reminder, deadline, or custom
event_type = db.Column(db.String(50), default='event', nullable=False, index=True)
# Optional associations
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)
client_id = db.Column(db.Integer, db.ForeignKey('clients.id'), nullable=True, index=True)
# Recurring event support
is_recurring = db.Column(db.Boolean, default=False, nullable=False)
recurrence_rule = db.Column(db.String(200), nullable=True) # RRULE format (e.g., "FREQ=WEEKLY;BYDAY=MO,WE,FR")
recurrence_end_date = db.Column(db.DateTime, nullable=True)
parent_event_id = db.Column(db.Integer, db.ForeignKey('calendar_events.id'), nullable=True, index=True)
# Reminders
reminder_minutes = db.Column(db.Integer, nullable=True) # Minutes before event to remind
# Color coding
color = db.Column(db.String(7), nullable=True) # Hex color code (e.g., #FF5733)
# Privacy
is_private = db.Column(db.Boolean, default=False, nullable=False)
created_at = db.Column(db.DateTime, default=now_in_app_timezone, nullable=False)
updated_at = db.Column(db.DateTime, default=now_in_app_timezone, onupdate=now_in_app_timezone, nullable=False)
# Relationships
user = db.relationship('User', backref=db.backref('calendar_events', lazy='dynamic', cascade='all, delete-orphan'))
project = db.relationship('Project', backref=db.backref('calendar_events', lazy='dynamic'))
task = db.relationship('Task', backref=db.backref('calendar_events', lazy='dynamic'))
client = db.relationship('Client', backref=db.backref('calendar_events', lazy='dynamic'))
# For recurring events - parent/child relationship
child_events = db.relationship(
'CalendarEvent',
backref=db.backref('parent_event', remote_side=[id]),
foreign_keys=[parent_event_id],
lazy='dynamic',
cascade='all, delete-orphan'
)
def __init__(self, user_id, title, start_time, end_time, **kwargs):
"""Initialize a CalendarEvent instance.
Args:
user_id: ID of the user who created this event
title: Title of the event
start_time: Start datetime of the event
end_time: End datetime of the event
**kwargs: Additional optional fields
"""
self.user_id = user_id
self.title = title
self.start_time = start_time
self.end_time = end_time
for key, value in kwargs.items():
if hasattr(self, key):
setattr(self, key, value)
def __repr__(self):
return f'<CalendarEvent {self.title} ({self.start_time})>'
def to_dict(self):
"""Convert event to dictionary for API responses"""
return {
'id': self.id,
'title': self.title,
'description': self.description,
'start': self.start_time.isoformat() if self.start_time else None,
'end': self.end_time.isoformat() if self.end_time else None,
'allDay': self.all_day,
'location': self.location,
'eventType': self.event_type,
'projectId': self.project_id,
'taskId': self.task_id,
'clientId': self.client_id,
'isRecurring': self.is_recurring,
'recurrenceRule': self.recurrence_rule,
'recurrenceEndDate': self.recurrence_end_date.isoformat() if self.recurrence_end_date else None,
'parentEventId': self.parent_event_id,
'reminderMinutes': self.reminder_minutes,
'color': self.color,
'isPrivate': self.is_private,
'createdAt': self.created_at.isoformat() if self.created_at else None,
'updatedAt': self.updated_at.isoformat() if self.updated_at else None,
}
def duration_hours(self):
"""Calculate duration of event in hours"""
if self.start_time and self.end_time:
delta = self.end_time - self.start_time
return delta.total_seconds() / 3600
return 0
@staticmethod
def get_events_in_range(user_id, start_date, end_date, include_tasks=False, include_time_entries=False):
"""Get all events for a user within a date range.
Args:
user_id: ID of the user
start_date: Start of date range
end_date: End of date range
include_tasks: Whether to include tasks with due dates
include_time_entries: Whether to include time entries
Returns:
Dictionary with events, tasks, and time entries
"""
from app.models import Task, TimeEntry
import logging
logger = logging.getLogger(__name__)
print(f"\n{'*'*80}")
print(f"MODEL - get_events_in_range called:")
print(f" user_id={user_id}")
print(f" start={start_date}")
print(f" end={end_date}")
print(f" include_tasks={include_tasks} (type: {type(include_tasks)})")
print(f" include_time_entries={include_time_entries} (type: {type(include_time_entries)})")
print(f"{'*'*80}\n")
logger.info(f"get_events_in_range called: user_id={user_id}, start={start_date}, end={end_date}, include_tasks={include_tasks}, include_time_entries={include_time_entries}")
result = {
'events': [],
'tasks': [],
'time_entries': []
}
# Get calendar events
events = CalendarEvent.query.filter(
CalendarEvent.user_id == user_id,
CalendarEvent.start_time >= start_date,
CalendarEvent.start_time <= end_date
).order_by(CalendarEvent.start_time).all()
logger.info(f"Found {len(events)} calendar events")
print(f"MODEL - Found {len(events)} calendar events")
result['events'] = [event.to_dict() for event in events]
# Optionally include tasks with due dates
if include_tasks:
print(f"MODEL - Querying tasks for user {user_id}")
logger.info(f"Querying tasks for user {user_id}")
tasks = Task.query.filter(
Task.assigned_to == user_id,
Task.due_date.isnot(None),
Task.due_date >= start_date.date() if hasattr(start_date, 'date') else start_date,
Task.due_date <= end_date.date() if hasattr(end_date, 'date') else end_date,
Task.status.in_(['todo', 'in_progress', 'review'])
).all()
print(f"MODEL - Found {len(tasks)} tasks with due dates")
logger.info(f"Found {len(tasks)} tasks with due dates")
result['tasks'] = [{
'id': task.id,
'title': task.name,
'description': task.description,
'dueDate': task.due_date.isoformat() if task.due_date else None,
'status': task.status,
'priority': task.priority,
'projectId': task.project_id,
'type': 'task'
} for task in tasks]
else:
print(f"MODEL - Not including tasks (include_tasks=False)")
logger.info("Not including tasks (include_tasks=False)")
# Optionally include time entries
if include_time_entries:
print(f"MODEL - Querying time entries for user {user_id}")
logger.info(f"Querying time entries for user {user_id}")
time_entries = TimeEntry.query.filter(
TimeEntry.user_id == user_id,
TimeEntry.start_time >= start_date,
TimeEntry.start_time <= end_date
).order_by(TimeEntry.start_time).all()
print(f"MODEL - Found {len(time_entries)} time entries")
logger.info(f"Found {len(time_entries)} time entries")
result['time_entries'] = [{
'id': entry.id,
'title': f"Time: {entry.project.name if entry.project else 'Unknown'}",
'start': entry.start_time.isoformat() if entry.start_time else None,
'end': entry.end_time.isoformat() if entry.end_time else None,
'projectId': entry.project_id,
'taskId': entry.task_id,
'notes': entry.notes,
'type': 'time_entry'
} for entry in time_entries]
else:
print(f"MODEL - Not including time entries (include_time_entries=False)")
logger.info("Not including time entries (include_time_entries=False)")
print(f"\n{'*'*80}")
print(f"MODEL - Returning:")
print(f" events: {len(result['events'])}")
print(f" tasks: {len(result['tasks'])}")
print(f" time_entries: {len(result['time_entries'])}")
print(f"{'*'*80}\n")
logger.info(f"Returning: {len(result['events'])} events, {len(result['tasks'])} tasks, {len(result['time_entries'])} time_entries")
return result

View File

@@ -821,13 +821,22 @@ def bulk_entries_action():
@api_bp.route('/api/calendar/events')
@login_required
def calendar_events():
"""Return calendar events for the current user in a date range with filtering and color coding."""
"""Return calendar events, tasks, and time entries for the current user in a date range."""
from app.models import CalendarEvent as CalendarEventModel
start = request.args.get('start')
end = request.args.get('end')
include_tasks = request.args.get('include_tasks', 'true').lower() == 'true'
include_time_entries = request.args.get('include_time_entries', 'true').lower() == 'true'
project_id = request.args.get('project_id', type=int)
task_id = request.args.get('task_id', type=int)
tags = request.args.get('tags', '').strip()
user_id = request.args.get('user_id', type=int) if current_user.is_admin else None
# Get user_id from query param (admins only) or default to current user
if current_user.is_admin and request.args.get('user_id'):
user_id = request.args.get('user_id', type=int)
else:
user_id = current_user.id
if not (start and end):
return jsonify({'error': 'start and end are required'}), 400
@@ -849,26 +858,14 @@ def calendar_events():
if not (start_dt and end_dt):
return jsonify({'error': 'Invalid date range'}), 400
# Build query with filters
q = TimeEntry.query
if user_id and current_user.is_admin:
q = q.filter(TimeEntry.user_id == user_id)
else:
q = q.filter(TimeEntry.user_id == current_user.id)
q = q.filter(TimeEntry.start_time < end_dt, (TimeEntry.end_time.is_(None)) | (TimeEntry.end_time > start_dt))
if project_id:
q = q.filter(TimeEntry.project_id == project_id)
if task_id:
q = q.filter(TimeEntry.task_id == task_id)
if tags:
q = q.filter(TimeEntry.tags.ilike(f'%{tags}%'))
items = q.order_by(TimeEntry.start_time.asc()).all()
events = []
now_local = local_now()
# Get all calendar items using the new method
result = CalendarEventModel.get_events_in_range(
user_id=user_id,
start_date=start_dt,
end_date=end_dt,
include_tasks=include_tasks,
include_time_entries=include_time_entries
)
# Color scheme for projects (deterministic based on project ID)
def get_project_color(project_id):
@@ -876,44 +873,81 @@ def calendar_events():
'#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
'#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16'
]
return colors[project_id % len(colors)]
return colors[project_id % len(colors)] if project_id else '#6b7280'
for e in items:
# Build detailed title
title_parts = []
if e.project:
title_parts.append(e.project.name)
if e.task:
title_parts.append(f"{e.task.name}")
elif e.notes:
note_preview = e.notes[:30] + ('...' if len(e.notes) > 30 else '')
title_parts.append(f"{note_preview}")
# Apply filters and format time entries
time_entries = []
for e in result.get('time_entries', []):
# Apply filters
if project_id and e.get('projectId') != project_id:
continue
if task_id and e.get('taskId') != task_id:
continue
if tags and tags.lower() not in (e.get('notes') or '').lower():
continue
ev = {
'id': e.id,
'title': ' '.join(title_parts) if title_parts else 'Time Entry',
'start': e.start_time.isoformat(),
'end': (e.end_time or now_local).isoformat(),
time_entries.append({
'id': e['id'],
'title': e['title'],
'start': e['start'],
'end': e['end'],
'editable': True,
'allDay': False,
'backgroundColor': get_project_color(e.project_id) if e.project_id else '#6b7280',
'borderColor': get_project_color(e.project_id) if e.project_id else '#6b7280',
'backgroundColor': get_project_color(e.get('projectId')),
'borderColor': get_project_color(e.get('projectId')),
'extendedProps': {
'project_id': e.project_id,
'project_name': e.project.name if e.project else None,
'task_id': e.task_id,
'task_name': e.task.name if e.task else None,
'notes': e.notes,
'tags': e.tags,
'billable': e.billable,
'duration_hours': e.duration_hours,
'user_id': e.user_id,
'source': e.source
**e,
'item_type': 'time_entry'
}
})
# Format tasks
tasks = []
for t in result.get('tasks', []):
tasks.append({
'id': t['id'],
'title': t['title'],
'start': t['dueDate'],
'end': t['dueDate'],
'allDay': True,
'editable': False,
'backgroundColor': '#f59e0b',
'borderColor': '#f59e0b',
'extendedProps': {
**t,
'item_type': 'task'
}
})
# Format calendar events
events = []
for ev in result.get('events', []):
events.append({
'id': ev['id'],
'title': ev['title'],
'start': ev['start'],
'end': ev['end'],
'allDay': ev.get('allDay', False),
'editable': True,
'backgroundColor': ev.get('color', '#3b82f6'),
'borderColor': ev.get('color', '#3b82f6'),
'extendedProps': {
**ev,
'item_type': 'event'
}
})
# Combine all items
all_items = events + tasks + time_entries
return jsonify({
'events': all_items,
'summary': {
'calendar_events': len(events),
'tasks': len(tasks),
'time_entries': len(time_entries)
}
events.append(ev)
return jsonify({'events': events})
})
@api_bp.route('/api/calendar/export')
@login_required

428
app/routes/calendar.py Normal file
View File

@@ -0,0 +1,428 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
from flask_babel import gettext as _
from flask_login import login_required, current_user
from app import db
from app.models import CalendarEvent, Task, Project, Client, TimeEntry
from datetime import datetime, timedelta
from app.utils.db import safe_commit
from app.utils.timezone import now_in_app_timezone
from app.utils.permissions import check_permission
calendar_bp = Blueprint('calendar', __name__)
@calendar_bp.route('/calendar')
@login_required
def view_calendar():
"""Display the calendar view with events, tasks, and time entries"""
view_type = request.args.get('view', 'month') # day, week, month
date_str = request.args.get('date', '')
# Parse the date or use today
if date_str:
try:
current_date = datetime.strptime(date_str, '%Y-%m-%d')
except ValueError:
current_date = now_in_app_timezone()
else:
current_date = now_in_app_timezone()
# Get projects and clients for event creation
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
clients = Client.query.filter_by(is_active=True).order_by(Client.name).all()
return render_template(
'calendar/view.html',
view_type=view_type,
current_date=current_date,
projects=projects,
clients=clients
)
@calendar_bp.route('/api/calendar/events')
@login_required
def get_events():
"""API endpoint to fetch calendar events for a date range"""
start_str = request.args.get('start')
end_str = request.args.get('end')
include_tasks = request.args.get('include_tasks', 'true').lower() == 'true'
include_time_entries = request.args.get('include_time_entries', 'true').lower() == 'true'
print(f"\n{'='*80}")
print(f"API ENDPOINT CALLED - /api/calendar/events")
print(f" include_tasks query param: {request.args.get('include_tasks')}")
print(f" include_time_entries query param: {request.args.get('include_time_entries')}")
print(f" include_tasks parsed: {include_tasks}")
print(f" include_time_entries parsed: {include_time_entries}")
print(f"{'='*80}\n")
if not start_str or not end_str:
return jsonify({'error': 'Start and end dates are required'}), 400
try:
start_date = datetime.fromisoformat(start_str.replace('Z', '+00:00'))
end_date = datetime.fromisoformat(end_str.replace('Z', '+00:00'))
except (ValueError, AttributeError):
return jsonify({'error': 'Invalid date format'}), 400
print(f"\n{'='*80}")
print(f"ROUTE HANDLER - get_events API:")
print(f" user_id={current_user.id}")
print(f" start_date={start_date}")
print(f" end_date={end_date}")
print(f" include_tasks={include_tasks} (type: {type(include_tasks)})")
print(f" include_time_entries={include_time_entries} (type: {type(include_time_entries)})")
print(f"{'='*80}\n")
# Get events using the model's static method
result = CalendarEvent.get_events_in_range(
user_id=current_user.id,
start_date=start_date,
end_date=end_date,
include_tasks=include_tasks,
include_time_entries=include_time_entries
)
print(f"\n{'='*80}")
print(f"ROUTE HANDLER - Result from get_events_in_range:")
print(f" events count: {len(result.get('events', []))}")
print(f" tasks count: {len(result.get('tasks', []))}")
print(f" time_entries count: {len(result.get('time_entries', []))}")
print(f"{'='*80}\n")
# Add debug marker to verify this code is running
result['_debug_timestamp'] = datetime.now().isoformat()
result['_debug_version'] = 'v3_no_cache'
response = jsonify(result)
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
@calendar_bp.route('/api/calendar/events', methods=['POST'])
@login_required
def create_event():
"""Create a new calendar event"""
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
# Validate required fields
required_fields = ['title', 'start', 'end']
for field in required_fields:
if field not in data:
return jsonify({'error': f'Missing required field: {field}'}), 400
try:
# Parse dates
start_time = datetime.fromisoformat(data['start'].replace('Z', '+00:00'))
end_time = datetime.fromisoformat(data['end'].replace('Z', '+00:00'))
# Create event
event = CalendarEvent(
user_id=current_user.id,
title=data['title'],
start_time=start_time,
end_time=end_time,
description=data.get('description'),
all_day=data.get('allDay', False),
location=data.get('location'),
event_type=data.get('eventType', 'event'),
project_id=data.get('projectId'),
task_id=data.get('taskId'),
client_id=data.get('clientId'),
is_recurring=data.get('isRecurring', False),
recurrence_rule=data.get('recurrenceRule'),
recurrence_end_date=datetime.fromisoformat(data['recurrenceEndDate'].replace('Z', '+00:00')) if data.get('recurrenceEndDate') else None,
reminder_minutes=data.get('reminderMinutes'),
color=data.get('color'),
is_private=data.get('isPrivate', False)
)
db.session.add(event)
if not safe_commit():
return jsonify({'error': 'Failed to create event'}), 500
return jsonify({
'success': True,
'event': event.to_dict(),
'message': _('Event created successfully')
}), 201
except (ValueError, AttributeError) as e:
return jsonify({'error': f'Invalid data: {str(e)}'}), 400
except Exception as e:
db.session.rollback()
return jsonify({'error': f'Error creating event: {str(e)}'}), 500
@calendar_bp.route('/api/calendar/events/<int:event_id>', methods=['GET'])
@login_required
def get_event(event_id):
"""Get a specific calendar event"""
event = CalendarEvent.query.get_or_404(event_id)
# Check if user has permission to view this event
if event.user_id != current_user.id and not current_user.is_admin:
return jsonify({'error': 'Permission denied'}), 403
return jsonify(event.to_dict())
@calendar_bp.route('/api/calendar/events/<int:event_id>', methods=['PUT'])
@login_required
def update_event(event_id):
"""Update a calendar event"""
event = CalendarEvent.query.get_or_404(event_id)
# Check if user has permission to edit this event
if event.user_id != current_user.id and not current_user.is_admin:
return jsonify({'error': 'Permission denied'}), 403
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
try:
# Update fields
if 'title' in data:
event.title = data['title']
if 'description' in data:
event.description = data['description']
if 'start' in data:
event.start_time = datetime.fromisoformat(data['start'].replace('Z', '+00:00'))
if 'end' in data:
event.end_time = datetime.fromisoformat(data['end'].replace('Z', '+00:00'))
if 'allDay' in data:
event.all_day = data['allDay']
if 'location' in data:
event.location = data['location']
if 'eventType' in data:
event.event_type = data['eventType']
if 'projectId' in data:
event.project_id = data['projectId']
if 'taskId' in data:
event.task_id = data['taskId']
if 'clientId' in data:
event.client_id = data['clientId']
if 'isRecurring' in data:
event.is_recurring = data['isRecurring']
if 'recurrenceRule' in data:
event.recurrence_rule = data['recurrenceRule']
if 'recurrenceEndDate' in data:
event.recurrence_end_date = datetime.fromisoformat(data['recurrenceEndDate'].replace('Z', '+00:00')) if data['recurrenceEndDate'] else None
if 'reminderMinutes' in data:
event.reminder_minutes = data['reminderMinutes']
if 'color' in data:
event.color = data['color']
if 'isPrivate' in data:
event.is_private = data['isPrivate']
event.updated_at = now_in_app_timezone()
if not safe_commit():
return jsonify({'error': 'Failed to update event'}), 500
return jsonify({
'success': True,
'event': event.to_dict(),
'message': _('Event updated successfully')
})
except (ValueError, AttributeError) as e:
return jsonify({'error': f'Invalid data: {str(e)}'}), 400
except Exception as e:
db.session.rollback()
return jsonify({'error': f'Error updating event: {str(e)}'}), 500
@calendar_bp.route('/api/calendar/events/<int:event_id>', methods=['DELETE', 'POST'])
@login_required
def delete_event(event_id):
"""Delete a calendar event"""
event = CalendarEvent.query.get_or_404(event_id)
# Check if user has permission to delete this event
if event.user_id != current_user.id and not current_user.is_admin:
if request.method == 'POST':
flash(_('You do not have permission to delete this event.'), 'error')
return redirect(url_for('calendar.view_calendar'))
return jsonify({'error': 'Permission denied'}), 403
try:
db.session.delete(event)
if not safe_commit():
if request.method == 'POST':
flash(_('Failed to delete event'), 'error')
return redirect(url_for('calendar.view_calendar'))
return jsonify({'error': 'Failed to delete event'}), 500
if request.method == 'POST':
flash(_('Event deleted successfully'), 'success')
return redirect(url_for('calendar.view_calendar'))
return jsonify({
'success': True,
'message': _('Event deleted successfully')
})
except Exception as e:
db.session.rollback()
if request.method == 'POST':
flash(_('Error deleting event: %(error)s', error=str(e)), 'error')
return redirect(url_for('calendar.view_calendar'))
return jsonify({'error': f'Error deleting event: {str(e)}'}), 500
@calendar_bp.route('/api/calendar/events/<int:event_id>/move', methods=['POST'])
@login_required
def move_event(event_id):
"""Move an event to a new time (drag and drop support)"""
event = CalendarEvent.query.get_or_404(event_id)
# Check if user has permission to edit this event
if event.user_id != current_user.id and not current_user.is_admin:
return jsonify({'error': 'Permission denied'}), 403
data = request.get_json()
if not data or 'start' not in data or 'end' not in data:
return jsonify({'error': 'Start and end times are required'}), 400
try:
event.start_time = datetime.fromisoformat(data['start'].replace('Z', '+00:00'))
event.end_time = datetime.fromisoformat(data['end'].replace('Z', '+00:00'))
event.updated_at = now_in_app_timezone()
if not safe_commit():
return jsonify({'error': 'Failed to move event'}), 500
return jsonify({
'success': True,
'event': event.to_dict(),
'message': _('Event moved successfully')
})
except (ValueError, AttributeError) as e:
return jsonify({'error': f'Invalid data: {str(e)}'}), 400
except Exception as e:
db.session.rollback()
return jsonify({'error': f'Error moving event: {str(e)}'}), 500
@calendar_bp.route('/api/calendar/events/<int:event_id>/resize', methods=['POST'])
@login_required
def resize_event(event_id):
"""Resize an event (change duration)"""
event = CalendarEvent.query.get_or_404(event_id)
# Check if user has permission to edit this event
if event.user_id != current_user.id and not current_user.is_admin:
return jsonify({'error': 'Permission denied'}), 403
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
try:
if 'end' in data:
event.end_time = datetime.fromisoformat(data['end'].replace('Z', '+00:00'))
elif 'start' in data:
event.start_time = datetime.fromisoformat(data['start'].replace('Z', '+00:00'))
event.updated_at = now_in_app_timezone()
if not safe_commit():
return jsonify({'error': 'Failed to resize event'}), 500
return jsonify({
'success': True,
'event': event.to_dict(),
'message': _('Event resized successfully')
})
except (ValueError, AttributeError) as e:
return jsonify({'error': f'Invalid data: {str(e)}'}), 400
except Exception as e:
db.session.rollback()
return jsonify({'error': f'Error resizing event: {str(e)}'}), 500
@calendar_bp.route('/calendar/event/<int:event_id>')
@login_required
def view_event(event_id):
"""View event details page"""
event = CalendarEvent.query.get_or_404(event_id)
# Check if user has permission to view this event
if event.user_id != current_user.id and not current_user.is_admin:
flash(_('You do not have permission to view this event.'), 'error')
return redirect(url_for('calendar.view_calendar'))
return render_template('calendar/event_detail.html', event=event)
@calendar_bp.route('/calendar/event/new')
@login_required
def new_event():
"""Create new event form"""
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
clients = Client.query.filter_by(is_active=True).order_by(Client.name).all()
tasks = Task.query.filter_by(assigned_to=current_user.id, status='in_progress').order_by(Task.name).all()
# Get date from query params if provided
date_str = request.args.get('date')
time_str = request.args.get('time')
initial_date = None
initial_time = None
if date_str:
try:
initial_date = datetime.strptime(date_str, '%Y-%m-%d').date()
except ValueError:
pass
if time_str:
try:
initial_time = datetime.strptime(time_str, '%H:%M').time()
except ValueError:
pass
return render_template(
'calendar/event_form.html',
projects=projects,
clients=clients,
tasks=tasks,
initial_date=initial_date,
initial_time=initial_time
)
@calendar_bp.route('/calendar/event/<int:event_id>/edit')
@login_required
def edit_event(event_id):
"""Edit event form"""
event = CalendarEvent.query.get_or_404(event_id)
# Check if user has permission to edit this event
if event.user_id != current_user.id and not current_user.is_admin:
flash(_('You do not have permission to edit this event.'), 'error')
return redirect(url_for('calendar.view_calendar'))
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
clients = Client.query.filter_by(is_active=True).order_by(Client.name).all()
tasks = Task.query.filter_by(assigned_to=current_user.id).order_by(Task.name).all()
return render_template(
'calendar/event_form.html',
event=event,
projects=projects,
clients=clients,
tasks=tasks,
edit_mode=True
)

472
app/static/calendar.css Normal file
View File

@@ -0,0 +1,472 @@
/* Calendar Styles for TimeTracker */
.calendar-container {
min-height: 600px;
}
/* Day View */
.calendar-day-view {
display: grid;
grid-template-columns: 80px 1fr;
gap: 1rem;
}
.time-slots {
border-right: 2px solid var(--border-color, #e2e8f0);
}
.time-slot {
height: 60px;
padding: 0.5rem;
font-size: 0.875rem;
color: var(--text-muted, #6b7280);
border-bottom: 1px solid var(--border-color, #e2e8f0);
}
.events-column {
position: relative;
}
.day-events-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.event-card {
padding: 0.75rem;
border-radius: 0.5rem;
border-left: 4px solid;
background-color: var(--card-bg, #ffffff);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: all 0.2s ease;
}
.event-card:hover {
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.15);
transform: translateY(-1px);
}
.event-card.event {
border-left-color: #3b82f6;
background-color: #eff6ff;
}
.event-card.task {
border-left-color: #f59e0b;
background-color: #fffbeb;
}
.event-card.time_entry {
border-left-color: #10b981;
background-color: #ecfdf5;
opacity: 0.9;
cursor: default;
pointer-events: none;
}
.event-card.time_entry::before {
content: "⏱ ";
font-size: 1.1em;
}
.event-card.task::before {
font-size: 1.1em;
}
.dark .event-card {
background-color: var(--card-dark-bg, #1e293b);
}
.dark .event-card.event {
background-color: #1e3a8a;
}
.dark .event-card.task {
background-color: #78350f;
}
.dark .event-card.time_entry {
background-color: #064e3b;
}
/* Week View */
.calendar-week-view {
overflow-x: auto;
}
.week-table {
width: 100%;
border-collapse: collapse;
min-width: 800px;
}
.week-table th {
padding: 1rem;
background-color: var(--header-bg, #f9fafb);
border: 1px solid var(--border-color, #e2e8f0);
font-weight: 600;
text-align: center;
}
.week-table th.today {
background-color: #dbeafe;
color: #1e40af;
}
.dark .week-table th {
background-color: var(--header-dark-bg, #1e293b);
}
.dark .week-table th.today {
background-color: #1e3a8a;
color: #93c5fd;
}
.week-cell {
height: 60px;
border: 1px solid var(--border-color, #e2e8f0);
padding: 0.25rem;
vertical-align: top;
position: relative;
}
.week-cell:hover {
background-color: var(--hover-bg, #f9fafb);
}
.dark .week-cell:hover {
background-color: var(--hover-dark-bg, #334155);
}
.event-chip {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
margin-bottom: 0.25rem;
border-radius: 0.25rem;
color: white;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: opacity 0.2s ease;
}
.event-chip:hover {
opacity: 0.85;
}
.event-chip.time-entry-chip {
background-color: #10b981 !important;
cursor: default !important;
opacity: 0.8 !important;
pointer-events: none;
}
.event-chip.task-chip {
background-color: #f59e0b !important;
cursor: pointer;
}
/* Month View */
.calendar-month-view {
overflow-x: auto;
}
.month-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.month-table th {
padding: 0.75rem;
background-color: var(--header-bg, #f9fafb);
border: 1px solid var(--border-color, #e2e8f0);
font-weight: 600;
text-align: center;
}
.dark .month-table th {
background-color: var(--header-dark-bg, #1e293b);
}
.month-cell {
height: 120px;
border: 1px solid var(--border-color, #e2e8f0);
padding: 0.5rem;
vertical-align: top;
cursor: pointer;
transition: background-color 0.2s ease;
}
.month-cell:hover {
background-color: var(--hover-bg, #f9fafb);
}
.dark .month-cell:hover {
background-color: var(--hover-dark-bg, #334155);
}
.month-cell.today {
background-color: #dbeafe;
}
.dark .month-cell.today {
background-color: #1e3a8a;
}
.month-cell.other-month {
opacity: 0.4;
}
.date-number {
font-weight: 600;
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
.month-cell.today .date-number {
background-color: #3b82f6;
color: white;
width: 24px;
height: 24px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
}
.month-events {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.event-badge {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
color: white;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: opacity 0.2s ease;
}
.event-badge:hover {
opacity: 0.85;
}
.event-badge.task-badge {
background-color: #f59e0b;
}
.event-badge.time-entry-badge {
background-color: #10b981;
cursor: default;
opacity: 0.8;
}
.event-badge-more {
font-size: 0.7rem;
color: var(--text-muted, #6b7280);
font-weight: 600;
margin-top: 0.25rem;
text-align: center;
}
/* Modal */
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-dialog {
background-color: var(--card-bg, #ffffff);
border-radius: 0.5rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
max-width: 600px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
.dark .modal-dialog {
background-color: var(--card-dark-bg, #1e293b);
}
.modal-header {
padding: 1.5rem;
border-bottom: 1px solid var(--border-color, #e2e8f0);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-title {
font-size: 1.5rem;
font-weight: 600;
margin: 0;
}
.modal-body {
padding: 1.5rem;
}
.modal-footer {
padding: 1.5rem;
border-top: 1px solid var(--border-color, #e2e8f0);
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
.close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-muted, #6b7280);
transition: color 0.2s ease;
}
.close:hover {
color: var(--text-color, #111827);
}
.dark .close:hover {
color: var(--text-dark-color, #f9fafb);
}
/* Button Group */
.btn-group {
display: inline-flex;
border-radius: 0.375rem;
overflow: hidden;
}
.btn-group .btn {
border-radius: 0;
margin: 0;
}
.btn-group .btn:first-child {
border-top-left-radius: 0.375rem;
border-bottom-left-radius: 0.375rem;
}
.btn-group .btn:last-child {
border-top-right-radius: 0.375rem;
border-bottom-right-radius: 0.375rem;
}
/* Responsive */
@media (max-width: 768px) {
.calendar-day-view {
grid-template-columns: 60px 1fr;
}
.time-slot {
font-size: 0.75rem;
padding: 0.25rem;
}
.month-cell {
height: 80px;
font-size: 0.75rem;
}
.week-table {
min-width: 600px;
}
.event-badge {
font-size: 0.65rem;
padding: 0.125rem 0.25rem;
}
}
/* Dark mode adjustments */
.dark {
--border-color: #374151;
--header-bg: #1e293b;
--header-dark-bg: #0f172a;
--card-bg: #1e293b;
--card-dark-bg: #0f172a;
--hover-bg: #334155;
--hover-dark-bg: #1e293b;
--text-muted: #9ca3af;
--text-color: #f9fafb;
--text-dark-color: #e5e7eb;
}
/* Loading state */
.calendar-container .text-center {
padding: 3rem;
}
/* Badge styles */
.badge {
display: inline-block;
padding: 0.25rem 0.75rem;
font-size: 0.75rem;
font-weight: 600;
line-height: 1;
border-radius: 0.25rem;
}
.badge-info {
background-color: #3b82f6;
color: white;
}
.badge-secondary {
background-color: #6b7280;
color: white;
}
/* Form styles for calendar forms */
.form-label.required::after {
content: ' *';
color: #ef4444;
}
.form-control {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color, #e2e8f0);
border-radius: 0.375rem;
background-color: var(--input-bg, #ffffff);
color: var(--text-color, #111827);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.form-control:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.dark .form-control {
background-color: var(--input-dark-bg, #0f172a);
color: var(--text-dark-color, #f9fafb);
}
.form-checkbox {
width: 1.25rem;
height: 1.25rem;
border-radius: 0.25rem;
cursor: pointer;
}

529
app/static/calendar.js Normal file
View File

@@ -0,0 +1,529 @@
/**
* Calendar functionality for TimeTracker
* Handles day, week, and month views with drag-and-drop support
*/
class Calendar {
constructor(options) {
this.viewType = options.viewType || 'month';
this.currentDate = new Date(options.currentDate || new Date());
this.container = document.getElementById('calendarContainer');
this.apiUrl = options.apiUrl;
this.events = [];
this.tasks = [];
this.timeEntries = [];
// Filters
this.showEvents = true;
this.showTasks = true;
this.showTimeEntries = true;
this.init();
}
init() {
this.setupEventListeners();
this.loadEvents();
}
setupEventListeners() {
// View navigation
document.getElementById('todayBtn')?.addEventListener('click', () => {
this.currentDate = new Date();
this.loadEvents();
});
document.getElementById('prevBtn')?.addEventListener('click', () => {
this.navigatePrevious();
});
document.getElementById('nextBtn')?.addEventListener('click', () => {
this.navigateNext();
});
// Filters
document.getElementById('showEvents')?.addEventListener('change', (e) => {
this.showEvents = e.target.checked;
this.render();
});
document.getElementById('showTasks')?.addEventListener('change', (e) => {
this.showTasks = e.target.checked;
this.render();
});
document.getElementById('showTimeEntries')?.addEventListener('change', (e) => {
this.showTimeEntries = e.target.checked;
this.render();
});
// Modal close
document.querySelectorAll('[data-dismiss="modal"]').forEach(btn => {
btn.addEventListener('click', () => {
document.getElementById('eventModal').style.display = 'none';
});
});
}
navigatePrevious() {
switch (this.viewType) {
case 'day':
this.currentDate.setDate(this.currentDate.getDate() - 1);
break;
case 'week':
this.currentDate.setDate(this.currentDate.getDate() - 7);
break;
case 'month':
this.currentDate.setMonth(this.currentDate.getMonth() - 1);
break;
}
this.loadEvents();
}
navigateNext() {
switch (this.viewType) {
case 'day':
this.currentDate.setDate(this.currentDate.getDate() + 1);
break;
case 'week':
this.currentDate.setDate(this.currentDate.getDate() + 7);
break;
case 'month':
this.currentDate.setMonth(this.currentDate.getMonth() + 1);
break;
}
this.loadEvents();
}
async loadEvents() {
const { start, end } = this.getDateRange();
try {
const url = new URL(this.apiUrl, window.location.origin);
url.searchParams.append('start', start.toISOString());
url.searchParams.append('end', end.toISOString());
url.searchParams.append('include_tasks', 'true');
url.searchParams.append('include_time_entries', 'true');
const response = await fetch(url);
const data = await response.json();
// Parse items by type (all items come in the 'events' array with item_type in extendedProps)
const allItems = data.events || [];
this.events = allItems.filter(item => item.extendedProps?.item_type === 'event');
this.tasks = allItems.filter(item => item.extendedProps?.item_type === 'task');
this.timeEntries = allItems.filter(item => item.extendedProps?.item_type === 'time_entry');
console.log('API Response:', {
total: allItems.length,
events: this.events.length,
tasks: this.tasks.length,
time_entries: this.timeEntries.length,
summary: data.summary,
rawData: data
});
this.render();
} catch (error) {
console.error('Error loading events:', error);
this.container.innerHTML = '<div class="text-center text-red-500 py-12">Error loading calendar data</div>';
}
}
getDateRange() {
let start, end;
switch (this.viewType) {
case 'day':
start = new Date(this.currentDate);
start.setHours(0, 0, 0, 0);
end = new Date(this.currentDate);
end.setHours(23, 59, 59, 999);
break;
case 'week':
const day = this.currentDate.getDay();
const diff = this.currentDate.getDate() - day + (day === 0 ? -6 : 1); // Monday as start
start = new Date(this.currentDate);
start.setDate(diff);
start.setHours(0, 0, 0, 0);
end = new Date(start);
end.setDate(start.getDate() + 6);
end.setHours(23, 59, 59, 999);
break;
case 'month':
start = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), 1);
end = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() + 1, 0, 23, 59, 59, 999);
break;
}
return { start, end };
}
render() {
this.updateTitle();
console.log('Calendar rendering:', {
viewType: this.viewType,
eventsCount: this.events.length,
tasksCount: this.tasks.length,
timeEntriesCount: this.timeEntries.length,
showEvents: this.showEvents,
showTasks: this.showTasks,
showTimeEntries: this.showTimeEntries
});
switch (this.viewType) {
case 'day':
this.renderDayView();
break;
case 'week':
this.renderWeekView();
break;
case 'month':
this.renderMonthView();
break;
}
}
updateTitle() {
const titleEl = document.getElementById('calendarTitle');
if (!titleEl) return;
const options = { month: 'long', year: 'numeric' };
switch (this.viewType) {
case 'day':
titleEl.textContent = this.currentDate.toLocaleDateString(undefined, {
weekday: 'long', month: 'long', day: 'numeric', year: 'numeric'
});
break;
case 'week':
const { start, end } = this.getDateRange();
titleEl.textContent = `${start.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} - ${end.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}`;
break;
case 'month':
titleEl.textContent = this.currentDate.toLocaleDateString(undefined, options);
break;
}
}
renderDayView() {
const html = `
<div class="calendar-day-view">
<div class="time-slots">
${this.renderTimeSlots()}
</div>
<div class="events-column">
${this.renderDayEvents()}
</div>
</div>
`;
this.container.innerHTML = html;
}
renderTimeSlots() {
const slots = [];
for (let hour = 0; hour < 24; hour++) {
const time = `${hour.toString().padStart(2, '0')}:00`;
slots.push(`<div class="time-slot" data-hour="${hour}">${time}</div>`);
}
return slots.join('');
}
renderDayEvents() {
const dayStart = new Date(this.currentDate);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(this.currentDate);
dayEnd.setHours(23, 59, 59, 999);
let html = '<div class="day-events-container">';
// Render events
if (this.showEvents) {
this.events.forEach(event => {
const eventStart = new Date(event.start);
if (eventStart >= dayStart && eventStart <= dayEnd) {
const time = eventStart.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
const eventTitle = this.escapeHtml(event.title);
const eventColor = event.color || '#3b82f6';
html += `
<div class="event-card event" data-id="${event.id}" data-type="event" style="border-left-color: ${eventColor}" onclick="window.calendar.showEventDetails(${event.id}, 'event')">
<i class="fas fa-calendar mr-2"></i>
<strong>${eventTitle}</strong>
<br><small>${time}</small>
</div>
`;
}
});
}
// Render tasks
if (this.showTasks) {
this.tasks.forEach(task => {
const taskTitle = this.escapeHtml(task.title);
const priorityIcons = { urgent: '🔴', high: '🟠', medium: '🟡', low: '🟢' };
const priorityIcon = priorityIcons[task.extendedProps?.priority] || '📋';
html += `
<div class="event-card task" data-id="${task.id}" data-type="task" onclick="window.open('/tasks/${task.id}', '_blank')">
${priorityIcon} <strong>${taskTitle}</strong>
<br><small>Due: ${task.start}</small>
<br><small class="text-xs">Status: ${task.extendedProps?.status || 'Unknown'}</small>
</div>
`;
});
}
// Render time entries
if (this.showTimeEntries) {
this.timeEntries.forEach(entry => {
const entryStart = new Date(entry.start);
if (entryStart >= dayStart && entryStart <= dayEnd) {
const startTime = entryStart.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
const entryTitle = this.escapeHtml(entry.title);
const notes = entry.notes ? `<br><small class="text-xs">${this.escapeHtml(entry.notes)}</small>` : '';
html += `
<div class="event-card time_entry" data-id="${entry.id}" data-type="time_entry">
⏱ <strong>${entryTitle}</strong>
<br><small>${startTime}</small>
${notes}
</div>
`;
}
});
}
html += '</div>';
return html;
}
renderWeekView() {
const { start } = this.getDateRange();
const days = [];
for (let i = 0; i < 7; i++) {
const day = new Date(start);
day.setDate(start.getDate() + i);
days.push(day);
}
let html = '<div class="calendar-week-view"><table class="week-table"><thead><tr>';
days.forEach(day => {
const isToday = this.isToday(day);
html += `<th class="${isToday ? 'today' : ''}">${day.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })}</th>`;
});
html += '</tr></thead><tbody>';
// Time slots for each day
for (let hour = 0; hour < 24; hour++) {
html += '<tr>';
days.forEach(day => {
html += `<td class="week-cell" data-date="${day.toISOString()}" data-hour="${hour}">`;
html += this.renderWeekCellEvents(day, hour);
html += '</td>';
});
html += '</tr>';
}
html += '</tbody></table></div>';
this.container.innerHTML = html;
}
renderWeekCellEvents(day, hour) {
const cellStart = new Date(day);
cellStart.setHours(hour, 0, 0, 0);
const cellEnd = new Date(day);
cellEnd.setHours(hour + 1, 0, 0, 0);
let html = '';
// Check events
if (this.showEvents) {
this.events.forEach(event => {
const eventStart = new Date(event.start);
if (eventStart >= cellStart && eventStart < cellEnd) {
const eventTitle = this.escapeHtml(event.title);
const eventColor = event.color || '#3b82f6';
html += `<div class="event-chip" style="background-color: ${eventColor}" onclick="window.calendar.showEventDetails(${event.id}, 'event')" title="${eventTitle}">📅 ${eventTitle}</div>`;
}
});
}
// Check tasks (only if they're due this hour)
if (this.showTasks) {
this.tasks.forEach(task => {
const taskDate = new Date(task.start);
// Show task if it's due on this day and hour 9 (morning)
if (taskDate.toDateString() === day.toDateString() && hour === 9) {
const taskTitle = this.escapeHtml(task.title);
html += `<div class="event-chip task-chip" style="background-color: #f59e0b" onclick="window.open('/tasks/${task.id}', '_blank'); event.stopPropagation();" title="${taskTitle}">📋 ${taskTitle}</div>`;
}
});
}
// Check time entries
if (this.showTimeEntries) {
this.timeEntries.forEach(entry => {
const entryStart = new Date(entry.start);
if (entryStart >= cellStart && entryStart < cellEnd) {
const entryTitle = this.escapeHtml(entry.title);
html += `<div class="event-chip time-entry-chip" style="background-color: #10b981; opacity: 0.8; cursor: default;" onclick="event.stopPropagation();" title="${entryTitle}">⏱ ${entryTitle}</div>`;
}
});
}
return html;
}
renderMonthView() {
const year = this.currentDate.getFullYear();
const month = this.currentDate.getMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const startDate = new Date(firstDay);
startDate.setDate(startDate.getDate() - (startDate.getDay() === 0 ? 6 : startDate.getDay() - 1));
let html = '<div class="calendar-month-view"><table class="month-table"><thead><tr>';
const weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
weekdays.forEach(day => {
html += `<th>${day}</th>`;
});
html += '</tr></thead><tbody>';
const currentDate = new Date(startDate);
for (let week = 0; week < 6; week++) {
html += '<tr>';
for (let day = 0; day < 7; day++) {
const isCurrentMonth = currentDate.getMonth() === month;
const isToday = this.isToday(currentDate);
html += `<td class="month-cell ${!isCurrentMonth ? 'other-month' : ''} ${isToday ? 'today' : ''}" data-date="${currentDate.toISOString()}">`;
html += `<div class="date-number">${currentDate.getDate()}</div>`;
html += this.renderMonthCellEvents(currentDate);
html += '</td>';
currentDate.setDate(currentDate.getDate() + 1);
}
html += '</tr>';
}
html += '</tbody></table></div>';
this.container.innerHTML = html;
// Add click handlers for cells
this.container.querySelectorAll('.month-cell').forEach(cell => {
cell.addEventListener('click', (e) => {
if (e.target.classList.contains('month-cell') || e.target.classList.contains('date-number')) {
const date = new Date(cell.dataset.date);
window.location.href = `${window.calendarData.newEventUrl}?date=${date.toISOString().split('T')[0]}`;
}
});
});
}
renderMonthCellEvents(day) {
const dayStart = new Date(day);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(day);
dayEnd.setHours(23, 59, 59, 999);
let html = '<div class="month-events">';
let count = 0;
const maxDisplay = 3;
// Events
if (this.showEvents) {
this.events.forEach(event => {
const eventStart = new Date(event.start);
if (eventStart >= dayStart && eventStart <= dayEnd) {
if (count < maxDisplay) {
const eventTitle = this.escapeHtml(event.title);
const eventColor = event.color || '#3b82f6';
html += `<div class="event-badge" style="background-color: ${eventColor}" onclick="window.calendar.showEventDetails(${event.id}, 'event'); event.stopPropagation();" title="${eventTitle}">📅 ${eventTitle}</div>`;
}
count++;
}
});
}
// Tasks
if (this.showTasks) {
this.tasks.forEach(task => {
const taskDate = new Date(task.start);
if (taskDate.toDateString() === day.toDateString()) {
if (count < maxDisplay) {
const taskTitle = this.escapeHtml(task.title);
html += `<div class="event-badge task-badge" onclick="window.open('/tasks/${task.id}', '_blank'); event.stopPropagation();" title="${taskTitle}">📋 ${taskTitle}</div>`;
}
count++;
}
});
}
// Time entries
if (this.showTimeEntries) {
this.timeEntries.forEach(entry => {
const entryStart = new Date(entry.start);
if (entryStart >= dayStart && entryStart <= dayEnd) {
if (count < maxDisplay) {
const entryTitle = this.escapeHtml(entry.title);
html += `<div class="event-badge time-entry-badge" onclick="event.stopPropagation();" title="${entryTitle}">⏱ ${entryTitle}</div>`;
}
count++;
}
});
}
if (count > maxDisplay) {
html += `<div class="event-badge-more">+${count - maxDisplay} more</div>`;
}
html += '</div>';
return html;
}
isToday(date) {
const today = new Date();
return date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear();
}
async showEventDetails(id, type) {
// Navigate to the appropriate detail page
if (type === 'event') {
window.location.href = `/calendar/event/${id}`;
} else if (type === 'task') {
window.location.href = `/tasks/${id}`;
} else if (type === 'time_entry') {
// Time entries are displayed for context only - they're not clickable
// Users can manage time entries via the Timer/Reports sections
console.log('Time entry clicked:', id);
}
}
escapeHtml(text) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text ? text.replace(/[&<>"']/g, m => map[m]) : '';
}
}
// Initialize calendar when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
if (typeof window.calendarData !== 'undefined') {
window.calendar = new Calendar({
viewType: window.calendarData.viewType,
currentDate: window.calendarData.currentDate,
apiUrl: window.calendarData.apiUrl
});
}
});

View File

@@ -122,6 +122,12 @@
<span class="ml-3 sidebar-label">{{ _('Weekly Goals') }}</span>
</a>
</li>
<li class="mt-2">
<a href="{{ url_for('calendar.view_calendar') }}" class="flex items-center p-2 rounded-lg {% if ep.startswith('calendar.') %}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-calendar-alt w-6 text-center"></i>
<span class="ml-3 sidebar-label">{{ _('Calendar') }}</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

@@ -0,0 +1,198 @@
{% extends "base.html" %}
{% from "components/ui.html" import confirm_dialog %}
{% block title %}{{ event.title }} - {{ app_name }}{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-6 max-w-4xl">
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-sm p-6">
<!-- Header -->
<div class="flex justify-between items-start mb-6">
<div>
<h1 class="text-3xl font-bold mb-2">{{ event.title }}</h1>
{% if event.event_type %}
<span class="badge badge-info">{{ event.event_type|title }}</span>
{% endif %}
{% if event.is_private %}
<span class="badge badge-secondary"><i class="fas fa-lock mr-1"></i>Private</span>
{% endif %}
</div>
<div class="flex gap-2">
<a href="{{ url_for('calendar.edit_event', event_id=event.id) }}" class="btn btn-sm btn-primary">
<i class="fas fa-edit mr-2"></i>{{ _('Edit') }}
</a>
<button type="button" class="btn btn-sm btn-danger"
onclick="document.getElementById('confirmDeleteEvent-{{ event.id }}').classList.remove('hidden')">
<i class="fas fa-trash mr-2"></i>{{ _('Delete') }}
</button>
<form id="confirmDeleteEvent-{{ event.id }}-form" method="POST" action="{{ url_for('calendar.delete_event', event_id=event.id) }}" class="hidden">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="_method" value="DELETE">
</form>
</div>
</div>
<!-- Event Details -->
<div class="space-y-4">
<!-- Date and Time -->
<div class="flex items-start">
<i class="fas fa-clock text-primary mt-1 mr-3 w-5"></i>
<div>
<p class="font-semibold">{{ _('Date & Time') }}</p>
<p class="text-muted">
{% if event.all_day %}
{{ event.start_time.strftime('%A, %B %d, %Y') }}
{% if event.start_time.date() != event.end_time.date() %}
- {{ event.end_time.strftime('%A, %B %d, %Y') }}
{% endif %}
<span class="badge badge-secondary ml-2">All Day</span>
{% else %}
{{ event.start_time.strftime('%A, %B %d, %Y at %I:%M %p') }}
- {{ event.end_time.strftime('%I:%M %p') }}
{% if event.start_time.date() != event.end_time.date() %}
({{ event.end_time.strftime('%B %d, %Y') }})
{% endif %}
{% endif %}
</p>
<p class="text-sm text-muted">{{ _('Duration') }}: {{ '%.2f'|format(event.duration_hours()) }} hours</p>
</div>
</div>
<!-- Description -->
{% if event.description %}
<div class="flex items-start">
<i class="fas fa-align-left text-primary mt-1 mr-3 w-5"></i>
<div>
<p class="font-semibold">{{ _('Description') }}</p>
<p class="text-muted whitespace-pre-wrap">{{ event.description }}</p>
</div>
</div>
{% endif %}
<!-- Location -->
{% if event.location %}
<div class="flex items-start">
<i class="fas fa-map-marker-alt text-primary mt-1 mr-3 w-5"></i>
<div>
<p class="font-semibold">{{ _('Location') }}</p>
<p class="text-muted">{{ event.location }}</p>
</div>
</div>
{% endif %}
<!-- Project -->
{% if event.project %}
<div class="flex items-start">
<i class="fas fa-project-diagram text-primary mt-1 mr-3 w-5"></i>
<div>
<p class="font-semibold">{{ _('Project') }}</p>
<p class="text-muted">
<a href="{{ url_for('projects.view_project', project_id=event.project.id) }}"
class="text-primary hover:underline">
{{ event.project.name }}
</a>
</p>
</div>
</div>
{% endif %}
<!-- Task -->
{% if event.task %}
<div class="flex items-start">
<i class="fas fa-tasks text-primary mt-1 mr-3 w-5"></i>
<div>
<p class="font-semibold">{{ _('Task') }}</p>
<p class="text-muted">
<a href="{{ url_for('tasks.view_task', task_id=event.task.id) }}"
class="text-primary hover:underline">
{{ event.task.name }}
</a>
</p>
</div>
</div>
{% endif %}
<!-- Client -->
{% if event.client %}
<div class="flex items-start">
<i class="fas fa-user-tie text-primary mt-1 mr-3 w-5"></i>
<div>
<p class="font-semibold">{{ _('Client') }}</p>
<p class="text-muted">
<a href="{{ url_for('clients.view_client', client_id=event.client.id) }}"
class="text-primary hover:underline">
{{ event.client.name }}
</a>
</p>
</div>
</div>
{% endif %}
<!-- Reminder -->
{% if event.reminder_minutes %}
<div class="flex items-start">
<i class="fas fa-bell text-primary mt-1 mr-3 w-5"></i>
<div>
<p class="font-semibold">{{ _('Reminder') }}</p>
<p class="text-muted">
{% if event.reminder_minutes < 60 %}
{{ event.reminder_minutes }} {{ _('minutes before') }}
{% elif event.reminder_minutes < 1440 %}
{{ (event.reminder_minutes / 60)|int }} {{ _('hours before') }}
{% else %}
{{ (event.reminder_minutes / 1440)|int }} {{ _('days before') }}
{% endif %}
</p>
</div>
</div>
{% endif %}
<!-- Recurring -->
{% if event.is_recurring %}
<div class="flex items-start">
<i class="fas fa-redo text-primary mt-1 mr-3 w-5"></i>
<div>
<p class="font-semibold">{{ _('Recurring') }}</p>
<p class="text-muted">
{% if event.recurrence_rule %}{{ event.recurrence_rule }}{% else %}Yes{% endif %}
{% if event.recurrence_end_date %}
<br>{{ _('Until') }}: {{ event.recurrence_end_date.strftime('%B %d, %Y') }}
{% endif %}
</p>
</div>
</div>
{% endif %}
<!-- Created/Updated -->
<div class="flex items-start">
<i class="fas fa-info-circle text-primary mt-1 mr-3 w-5"></i>
<div>
<p class="font-semibold">{{ _('Information') }}</p>
<p class="text-sm text-muted">
{{ _('Created') }}: {{ event.created_at.strftime('%B %d, %Y at %I:%M %p') }}<br>
{{ _('Last Updated') }}: {{ event.updated_at.strftime('%B %d, %Y at %I:%M %p') }}
</p>
</div>
</div>
</div>
<!-- Back Button -->
<div class="mt-6 pt-6 border-t border-border-light dark:border-border-dark">
<a href="{{ url_for('calendar.view_calendar') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left mr-2"></i>{{ _('Back to Calendar') }}
</a>
</div>
</div>
</div>
<!-- Delete Confirmation Dialog -->
{{ confirm_dialog(
'confirmDeleteEvent-' ~ event.id,
_('Delete Event'),
_('Are you sure you want to delete this event? This action cannot be undone.'),
_('Delete'),
_('Cancel'),
'danger'
) }}
{% endblock %}

View File

@@ -0,0 +1,316 @@
{% extends "base.html" %}
{% block title %}{% if edit_mode %}{{ _('Edit Event') }}{% else %}{{ _('New Event') }}{% endif %} - {{ app_name }}{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-6 max-w-3xl">
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-sm p-6">
<div class="mb-6">
<h1 class="text-3xl font-bold">
<i class="fas fa-calendar-plus mr-2 text-primary"></i>
{% if edit_mode %}{{ _('Edit Event') }}{% else %}{{ _('New Event') }}{% endif %}
</h1>
</div>
<form id="eventForm" method="POST" action="{% if edit_mode %}{{ url_for('calendar.update_event', event_id=event.id) }}{% else %}{{ url_for('calendar.create_event') }}{% endif %}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- Title -->
<div class="form-group mb-4">
<label for="title" class="form-label required">{{ _('Title') }}</label>
<input type="text"
id="title"
name="title"
class="form-control"
required
value="{% if event %}{{ event.title }}{% endif %}">
</div>
<!-- Description -->
<div class="form-group mb-4">
<label for="description" class="form-label">{{ _('Description') }}</label>
<textarea id="description"
name="description"
class="form-control"
rows="3">{% if event %}{{ event.description or '' }}{% endif %}</textarea>
</div>
<!-- Date and Time -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div class="form-group">
<label for="startDate" class="form-label required">{{ _('Start Date') }}</label>
<input type="date"
id="startDate"
name="startDate"
class="form-control"
required
value="{% if event %}{{ event.start_time.strftime('%Y-%m-%d') }}{% elif initial_date %}{{ initial_date.strftime('%Y-%m-%d') }}{% endif %}">
</div>
<div class="form-group">
<label for="startTime" class="form-label">{{ _('Start Time') }}</label>
<input type="time"
id="startTime"
name="startTime"
class="form-control"
value="{% if event %}{{ event.start_time.strftime('%H:%M') }}{% elif initial_time %}{{ initial_time.strftime('%H:%M') }}{% else %}09:00{% endif %}">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div class="form-group">
<label for="endDate" class="form-label required">{{ _('End Date') }}</label>
<input type="date"
id="endDate"
name="endDate"
class="form-control"
required
value="{% if event %}{{ event.end_time.strftime('%Y-%m-%d') }}{% elif initial_date %}{{ initial_date.strftime('%Y-%m-%d') }}{% endif %}">
</div>
<div class="form-group">
<label for="endTime" class="form-label">{{ _('End Time') }}</label>
<input type="time"
id="endTime"
name="endTime"
class="form-control"
value="{% if event %}{{ event.end_time.strftime('%H:%M') }}{% else %}10:00{% endif %}">
</div>
</div>
<!-- All Day Event -->
<div class="form-group mb-4">
<label class="inline-flex items-center">
<input type="checkbox"
id="allDay"
name="allDay"
class="form-checkbox"
{% if event and event.all_day %}checked{% endif %}>
<span class="ml-2">{{ _('All Day Event') }}</span>
</label>
</div>
<!-- Location -->
<div class="form-group mb-4">
<label for="location" class="form-label">{{ _('Location') }}</label>
<input type="text"
id="location"
name="location"
class="form-control"
value="{% if event %}{{ event.location or '' }}{% endif %}">
</div>
<!-- Event Type -->
<div class="form-group mb-4">
<label for="eventType" class="form-label">{{ _('Event Type') }}</label>
<select id="eventType" name="eventType" class="form-control">
<option value="event" {% if event and event.event_type == 'event' %}selected{% endif %}>{{ _('Event') }}</option>
<option value="meeting" {% if event and event.event_type == 'meeting' %}selected{% endif %}>{{ _('Meeting') }}</option>
<option value="appointment" {% if event and event.event_type == 'appointment' %}selected{% endif %}>{{ _('Appointment') }}</option>
<option value="reminder" {% if event and event.event_type == 'reminder' %}selected{% endif %}>{{ _('Reminder') }}</option>
<option value="deadline" {% if event and event.event_type == 'deadline' %}selected{% endif %}>{{ _('Deadline') }}</option>
</select>
</div>
<!-- Associated Project -->
<div class="form-group mb-4">
<label for="projectId" class="form-label">{{ _('Project') }}</label>
<select id="projectId" name="projectId" class="form-control">
<option value="">{{ _('-- None --') }}</option>
{% for project in projects %}
<option value="{{ project.id }}" {% if event and event.project_id == project.id %}selected{% endif %}>
{{ project.name }}
</option>
{% endfor %}
</select>
</div>
<!-- Associated Task -->
<div class="form-group mb-4">
<label for="taskId" class="form-label">{{ _('Task') }}</label>
<select id="taskId" name="taskId" class="form-control">
<option value="">{{ _('-- None --') }}</option>
{% for task in tasks %}
<option value="{{ task.id }}" {% if event and event.task_id == task.id %}selected{% endif %}>
{{ task.name }}
</option>
{% endfor %}
</select>
</div>
<!-- Associated Client -->
<div class="form-group mb-4">
<label for="clientId" class="form-label">{{ _('Client') }}</label>
<select id="clientId" name="clientId" class="form-control">
<option value="">{{ _('-- None --') }}</option>
{% for client in clients %}
<option value="{{ client.id }}" {% if event and event.client_id == client.id %}selected{% endif %}>
{{ client.name }}
</option>
{% endfor %}
</select>
</div>
<!-- Reminder -->
<div class="form-group mb-4">
<label for="reminderMinutes" class="form-label">{{ _('Reminder') }}</label>
<select id="reminderMinutes" name="reminderMinutes" class="form-control">
<option value="">{{ _('No reminder') }}</option>
<option value="5" {% if event and event.reminder_minutes == 5 %}selected{% endif %}>{{ _('5 minutes before') }}</option>
<option value="15" {% if event and event.reminder_minutes == 15 %}selected{% endif %}>{{ _('15 minutes before') }}</option>
<option value="30" {% if event and event.reminder_minutes == 30 %}selected{% endif %}>{{ _('30 minutes before') }}</option>
<option value="60" {% if event and event.reminder_minutes == 60 %}selected{% endif %}>{{ _('1 hour before') }}</option>
<option value="1440" {% if event and event.reminder_minutes == 1440 %}selected{% endif %}>{{ _('1 day before') }}</option>
</select>
</div>
<!-- Color -->
<div class="form-group mb-4">
<label for="color" class="form-label">{{ _('Color') }}</label>
<div class="flex gap-2 items-center">
<input type="color"
id="color"
name="color"
class="form-control w-20 h-10"
value="{% if event and event.color %}{{ event.color }}{% else %}#3b82f6{% endif %}">
<span class="text-sm text-muted">{{ _('Choose a color for this event') }}</span>
</div>
</div>
<!-- Private Event -->
<div class="form-group mb-4">
<label class="inline-flex items-center">
<input type="checkbox"
id="isPrivate"
name="isPrivate"
class="form-checkbox"
{% if event and event.is_private %}checked{% endif %}>
<span class="ml-2">{{ _('Private Event') }}</span>
</label>
<p class="text-sm text-muted mt-1">{{ _('Private events are only visible to you') }}</p>
</div>
<!-- Recurring Event Section -->
<div class="border-t border-border-light dark:border-border-dark pt-4 mt-6 mb-4">
<h3 class="text-lg font-semibold mb-3">{{ _('Recurring Event') }}</h3>
<div class="form-group mb-4">
<label class="inline-flex items-center">
<input type="checkbox"
id="isRecurring"
name="isRecurring"
class="form-checkbox"
{% if event and event.is_recurring %}checked{% endif %}>
<span class="ml-2">{{ _('This is a recurring event') }}</span>
</label>
</div>
<div id="recurringOptions" style="{% if not event or not event.is_recurring %}display: none;{% endif %}">
<div class="form-group mb-4">
<label for="recurrenceRule" class="form-label">{{ _('Recurrence Pattern') }}</label>
<input type="text"
id="recurrenceRule"
name="recurrenceRule"
class="form-control"
placeholder="FREQ=WEEKLY;BYDAY=MO,WE,FR"
value="{% if event %}{{ event.recurrence_rule or '' }}{% endif %}">
<p class="text-sm text-muted mt-1">{{ _('Use RRULE format (e.g., FREQ=WEEKLY;BYDAY=MO,WE,FR)') }}</p>
</div>
<div class="form-group mb-4">
<label for="recurrenceEndDate" class="form-label">{{ _('Recurrence End Date') }}</label>
<input type="date"
id="recurrenceEndDate"
name="recurrenceEndDate"
class="form-control"
value="{% if event and event.recurrence_end_date %}{{ event.recurrence_end_date.strftime('%Y-%m-%d') }}{% endif %}">
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="flex gap-3 mt-6">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save mr-2"></i>
{% if edit_mode %}{{ _('Update Event') }}{% else %}{{ _('Create Event') }}{% endif %}
</button>
<a href="{{ url_for('calendar.view_calendar') }}" class="btn btn-secondary">
{{ _('Cancel') }}
</a>
</div>
</form>
</div>
</div>
<script>
// Show/hide recurring options
document.getElementById('isRecurring').addEventListener('change', function() {
const recurringOptions = document.getElementById('recurringOptions');
recurringOptions.style.display = this.checked ? 'block' : 'none';
});
// Disable time inputs when all day is checked
document.getElementById('allDay').addEventListener('change', function() {
const startTime = document.getElementById('startTime');
const endTime = document.getElementById('endTime');
startTime.disabled = this.checked;
endTime.disabled = this.checked;
});
// Form submission via AJAX
document.getElementById('eventForm').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(this);
const data = {};
// Build event data
const startDate = formData.get('startDate');
const startTime = formData.get('startTime') || '00:00';
const endDate = formData.get('endDate');
const endTime = formData.get('endTime') || '23:59';
data.title = formData.get('title');
data.description = formData.get('description') || '';
data.start = `${startDate}T${startTime}:00`;
data.end = `${endDate}T${endTime}:00`;
data.allDay = formData.get('allDay') === 'on';
data.location = formData.get('location') || '';
data.eventType = formData.get('eventType') || 'event';
data.projectId = formData.get('projectId') ? parseInt(formData.get('projectId')) : null;
data.taskId = formData.get('taskId') ? parseInt(formData.get('taskId')) : null;
data.clientId = formData.get('clientId') ? parseInt(formData.get('clientId')) : null;
data.reminderMinutes = formData.get('reminderMinutes') ? parseInt(formData.get('reminderMinutes')) : null;
data.color = formData.get('color') || '#3b82f6';
data.isPrivate = formData.get('isPrivate') === 'on';
data.isRecurring = formData.get('isRecurring') === 'on';
data.recurrenceRule = formData.get('recurrenceRule') || '';
data.recurrenceEndDate = formData.get('recurrenceEndDate') || null;
try {
const url = this.action;
const method = {% if edit_mode %}'PUT'{% else %}'POST'{% endif %};
const response = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': formData.get('csrf_token')
},
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok && result.success) {
window.location.href = '{{ url_for('calendar.view_calendar') }}';
} else {
alert(result.error || 'An error occurred');
}
} catch (error) {
console.error('Error:', error);
alert('An error occurred while saving the event');
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,139 @@
{% extends "base.html" %}
{% block title %}Calendar - {{ app_name }}{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{{ url_for('static', filename='calendar.css') }}?v=2">
{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-6">
<!-- Calendar Header -->
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-sm p-6 mb-6">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<h1 class="text-3xl font-bold">
<i class="fas fa-calendar-alt mr-2 text-primary"></i>
{{ _('Calendar') }}
</h1>
<div class="flex flex-wrap gap-3">
<!-- View Type Selector -->
<div class="btn-group" role="group">
<a href="{{ url_for('calendar.view_calendar', view='day', date=current_date.strftime('%Y-%m-%d')) }}"
class="btn btn-sm {% if view_type == 'day' %}btn-primary{% else %}btn-secondary{% endif %}">
<i class="fas fa-calendar-day mr-1"></i> {{ _('Day') }}
</a>
<a href="{{ url_for('calendar.view_calendar', view='week', date=current_date.strftime('%Y-%m-%d')) }}"
class="btn btn-sm {% if view_type == 'week' %}btn-primary{% else %}btn-secondary{% endif %}">
<i class="fas fa-calendar-week mr-1"></i> {{ _('Week') }}
</a>
<a href="{{ url_for('calendar.view_calendar', view='month', date=current_date.strftime('%Y-%m-%d')) }}"
class="btn btn-sm {% if view_type == 'month' %}btn-primary{% else %}btn-secondary{% endif %}">
<i class="fas fa-calendar mr-1"></i> {{ _('Month') }}
</a>
</div>
<!-- Add Event Button -->
<a href="{{ url_for('calendar.new_event', date=current_date.strftime('%Y-%m-%d')) }}"
class="btn btn-primary btn-sm">
<i class="fas fa-plus mr-2"></i>
{{ _('New Event') }}
</a>
</div>
</div>
<!-- Calendar Navigation -->
<div class="flex items-center justify-between mt-6">
<button id="prevBtn" class="btn btn-sm btn-secondary">
<i class="fas fa-chevron-left"></i>
</button>
<div class="flex items-center gap-4">
<button id="todayBtn" class="btn btn-sm btn-secondary">
{{ _('Today') }}
</button>
<h2 id="calendarTitle" class="text-2xl font-semibold"></h2>
</div>
<button id="nextBtn" class="btn btn-sm btn-secondary">
<i class="fas fa-chevron-right"></i>
</button>
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-3 mt-4">
<label class="inline-flex items-center">
<input type="checkbox" id="showEvents" checked class="form-checkbox">
<span class="ml-2">{{ _('Events') }}</span>
</label>
<label class="inline-flex items-center">
<input type="checkbox" id="showTasks" checked class="form-checkbox">
<span class="ml-2">{{ _('Tasks') }}</span>
</label>
<label class="inline-flex items-center">
<input type="checkbox" id="showTimeEntries" checked class="form-checkbox">
<span class="ml-2">{{ _('Time Entries') }}</span>
</label>
</div>
</div>
<!-- Calendar Grid -->
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-sm p-6">
<div id="calendarContainer" class="calendar-container">
<!-- Calendar will be rendered here by JavaScript -->
<div class="text-center py-12">
<i class="fas fa-spinner fa-spin text-4xl text-primary mb-4"></i>
<p class="text-muted">{{ _('Loading calendar...') }}</p>
</div>
</div>
</div>
<!-- Event Details Modal -->
<div id="eventModal" class="modal" style="display: none;">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">{{ _('Event Details') }}</h3>
<button type="button" class="close" data-dismiss="modal">
<i class="fas fa-times"></i>
</button>
</div>
<div class="modal-body" id="eventDetails">
<!-- Event details will be loaded here -->
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">
{{ _('Close') }}
</button>
<a id="editEventBtn" href="#" class="btn btn-primary">
<i class="fas fa-edit mr-2"></i>{{ _('Edit') }}
</a>
<button id="deleteEventBtn" type="button" class="btn btn-danger">
<i class="fas fa-trash mr-2"></i>{{ _('Delete') }}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Pass data to JavaScript -->
<script>
window.calendarData = {
viewType: '{{ view_type }}',
currentDate: '{{ current_date.strftime('%Y-%m-%d') }}',
apiUrl: '{{ url_for('calendar.get_events') }}',
viewUrl: '{{ url_for('calendar.view_calendar') }}',
newEventUrl: '{{ url_for('calendar.new_event') }}',
editEventUrl: '{{ url_for('calendar.edit_event', event_id=0) }}'.replace('/0', ''),
deleteEventUrl: '{{ url_for('calendar.delete_event', event_id=0) }}'.replace('/0', ''),
moveEventUrl: '{{ url_for('calendar.move_event', event_id=0) }}'.replace('/0', ''),
resizeEventUrl: '{{ url_for('calendar.resize_event', event_id=0) }}'.replace('/0', ''),
csrfToken: '{{ csrf_token() }}'
};
</script>
{% endblock %}
{% block scripts_extra %}
<script src="{{ url_for('static', filename='calendar.js') }}?v=9"></script>
{% endblock %}

View File

@@ -0,0 +1,446 @@
# Calendar/Agenda Support Documentation
## Overview
The Calendar/Agenda feature in TimeTracker provides a comprehensive view of all your events, tasks, and time entries in one place. This feature helps you plan your work, schedule meetings, and track deadlines more effectively.
## Features
### Calendar Views
The calendar supports three different view modes:
1. **Day View**: Shows hourly time slots for a single day with all events and time entries
2. **Week View**: Displays a weekly grid with events across 7 days
3. **Month View**: Traditional monthly calendar with events displayed on each day
### Event Management
#### Event Types
- **Event**: General calendar events
- **Meeting**: Scheduled meetings with clients or team members
- **Appointment**: One-on-one appointments
- **Reminder**: Simple reminders for tasks or deadlines
- **Deadline**: Important deadlines linked to tasks or projects
#### Event Properties
Each calendar event can have the following properties:
- **Title** (required): The name of the event
- **Description**: Detailed description of the event
- **Start Time** (required): When the event starts
- **End Time** (required): When the event ends
- **All-Day**: Mark event as all-day (no specific time)
- **Location**: Physical or virtual location
- **Color**: Custom color for visual organization
- **Reminder**: Set reminder (5, 15, 30 minutes, 1 hour, or 1 day before)
- **Private**: Mark event as private (visible only to you)
#### Associated Items
Events can be linked to:
- **Project**: Associate event with a specific project
- **Task**: Link event to a task for better tracking
- **Client**: Connect event to a client
### Recurring Events
Create events that repeat on a schedule:
- Set recurrence pattern using RRULE format
- Example: `FREQ=WEEKLY;BYDAY=MO,WE,FR` for events every Monday, Wednesday, and Friday
- Set an optional end date for the recurrence
### Integration with Tasks and Time Entries
The calendar automatically displays:
- **Tasks with due dates**: Shown as badges on their due date
- **Time entries**: Your tracked time appears on the calendar
- Toggle visibility of these items using the filter checkboxes
## User Guide
### Accessing the Calendar
1. Log in to TimeTracker
2. Click on the **Calendar** link in the navigation menu
3. The calendar will open with the current month view
### Creating a New Event
#### Method 1: Using the "New Event" Button
1. Click the **"New Event"** button at the top of the calendar
2. Fill in the event details:
- Enter a title
- Set start and end dates/times
- Add optional description, location, etc.
- Link to project, task, or client if desired
3. Click **"Create Event"** to save
#### Method 2: Quick Creation (Month View)
1. In month view, click on any date cell
2. This opens the new event form with the date pre-filled
3. Complete the event details and save
### Viewing Events
#### In Calendar View
- Events appear as colored badges on their scheduled dates
- In month view, up to 3 events are shown per day
- If more than 3 events exist, a "+X more" indicator appears
- Click any event badge to view its details
#### Event Detail Page
1. Click on an event to view its full details
2. The detail page shows:
- Full event information
- Associated project, task, or client (with links)
- Duration calculation
- Created and updated timestamps
### Editing Events
1. Click on an event to open its detail page
2. Click the **"Edit"** button
3. Make your changes
4. Click **"Update Event"** to save
### Deleting Events
1. Open the event detail page
2. Click the **"Delete"** button
3. Confirm the deletion
### Drag and Drop (Coming Soon)
Future versions will support:
- Dragging events to reschedule them
- Resizing events to adjust duration
### Filtering the Calendar
Use the checkboxes at the top of the calendar to toggle visibility:
- **Events**: Show/hide calendar events
- **Tasks**: Show/hide tasks with due dates
- **Time Entries**: Show/hide tracked time
### Navigation
- **Today**: Jump to today's date
- **Previous/Next**: Navigate to previous/next day, week, or month
- **Date Selector**: Click on the date display to pick a specific date
## API Documentation
### API Endpoints
#### Get Events in Date Range
```http
GET /api/calendar/events?start={start_date}&end={end_date}&include_tasks={boolean}&include_time_entries={boolean}
```
**Parameters:**
- `start`: ISO 8601 datetime (required)
- `end`: ISO 8601 datetime (required)
- `include_tasks`: Include tasks with due dates (default: true)
- `include_time_entries`: Include time entries (default: true)
**Response:**
```json
{
"events": [...],
"tasks": [...],
"time_entries": [...]
}
```
#### Create Event
```http
POST /api/calendar/events
Content-Type: application/json
```
**Request Body:**
```json
{
"title": "Team Meeting",
"description": "Weekly sync",
"start": "2025-01-15T10:00:00",
"end": "2025-01-15T11:00:00",
"allDay": false,
"location": "Conference Room A",
"eventType": "meeting",
"projectId": 1,
"taskId": null,
"clientId": null,
"color": "#3b82f6",
"reminderMinutes": 30,
"isPrivate": false,
"isRecurring": false,
"recurrenceRule": null,
"recurrenceEndDate": null
}
```
**Response:**
```json
{
"success": true,
"event": { /* event object */ },
"message": "Event created successfully"
}
```
#### Update Event
```http
PUT /api/calendar/events/{event_id}
Content-Type: application/json
```
**Request Body:** Same as create (partial updates supported)
#### Delete Event
```http
DELETE /api/calendar/events/{event_id}
```
**Response:**
```json
{
"success": true,
"message": "Event deleted successfully"
}
```
#### Move Event (Drag & Drop)
```http
POST /api/calendar/events/{event_id}/move
Content-Type: application/json
```
**Request Body:**
```json
{
"start": "2025-01-16T10:00:00",
"end": "2025-01-16T11:00:00"
}
```
#### Resize Event
```http
POST /api/calendar/events/{event_id}/resize
Content-Type: application/json
```
**Request Body:**
```json
{
"end": "2025-01-15T12:00:00"
}
```
## Database Schema
### CalendarEvent Model
```python
class CalendarEvent(db.Model):
id = Integer (Primary Key)
user_id = Integer (Foreign Key to users.id)
title = String(200) (Required)
description = Text
start_time = DateTime (Required, Indexed)
end_time = DateTime (Required, Indexed)
all_day = Boolean (Default: False)
location = String(200)
event_type = String(50) (Default: 'event', Indexed)
# Associations
project_id = Integer (Foreign Key to projects.id)
task_id = Integer (Foreign Key to tasks.id)
client_id = Integer (Foreign Key to clients.id)
# Recurring events
is_recurring = Boolean (Default: False)
recurrence_rule = String(200) # RRULE format
recurrence_end_date = DateTime
parent_event_id = Integer (Foreign Key to calendar_events.id)
# Reminders and customization
reminder_minutes = Integer
color = String(7) # Hex color code
is_private = Boolean (Default: False)
# Timestamps
created_at = DateTime
updated_at = DateTime
```
### Relationships
- `user`: Many-to-one relationship with User
- `project`: Many-to-one relationship with Project
- `task`: Many-to-one relationship with Task
- `client`: Many-to-one relationship with Client
- `parent_event`: Self-referential for recurring event instances
- `child_events`: One-to-many relationship for recurring event series
## Migration
The calendar feature is added via Alembic migration:
```bash
# Migration file: migrations/versions/034_add_calendar_events_table.py
flask db upgrade
```
This creates the `calendar_events` table with all necessary indexes and foreign key constraints.
## Permissions
- **Users** can:
- Create their own events
- View their own events
- Edit their own events
- Delete their own events
- View events linked to their assigned tasks
- **Admins** can:
- View all events (except private events of other users)
- Edit any event
- Delete any event
## Best Practices
### Event Organization
1. **Use Colors Wisely**: Assign colors to different event types for quick visual identification
- Blue (#3b82f6) for regular meetings
- Red (#ef4444) for deadlines
- Green (#10b981) for client appointments
- Purple (#8b5cf6) for personal events
2. **Link to Projects**: Always link events to projects when relevant for better reporting
3. **Set Reminders**: Use reminders for important meetings to avoid missing them
4. **Use Recurring Events**: Set up recurring events for weekly meetings instead of creating them manually
### Performance Tips
1. The calendar loads events for the visible date range only
2. Large organizations should consider archiving old events (older than 6 months)
3. Use the filters to focus on what's important
### Integration with Workflows
1. **Task Planning**: Create events for task work sessions
2. **Client Meetings**: Link meetings to clients for better relationship tracking
3. **Project Milestones**: Use deadline events for project milestones
4. **Time Blocking**: Create events to block time for focused work
## Troubleshooting
### Events Not Showing
1. Check date range - ensure events fall within the visible calendar range
2. Verify filters - ensure event type is not filtered out
3. Check permissions - private events are only visible to their creator
### Cannot Edit Event
- Verify you are the event owner or an admin
- Check that the event still exists
- Ensure you're logged in with the correct account
### Recurring Events Not Working
- Verify RRULE format is correct
- Check that recurrence end date is after start date
- Ensure parent event exists for child instances
## Technical Details
### Frontend
- **JavaScript**: `app/static/calendar.js` - Calendar rendering and interaction
- **CSS**: `app/static/calendar.css` - Calendar styling
- **Templates**: `app/templates/calendar/` - HTML templates
### Backend
- **Models**: `app/models/calendar_event.py` - Data model
- **Routes**: `app/routes/calendar.py` - API and view routes
- **Tests**: `tests/test_calendar_event_model.py`, `tests/test_calendar_routes.py`
### Testing
Run calendar tests:
```bash
# Model tests
pytest tests/test_calendar_event_model.py -v
# Route tests
pytest tests/test_calendar_routes.py -v
# All calendar tests
pytest tests/test_calendar* -v
# Smoke tests
pytest tests/test_calendar* -m smoke
```
## Future Enhancements
Potential future improvements:
1. **iCal/ICS Import/Export**: Import events from other calendar applications
2. **Sharing**: Share events with other users or teams
3. **Email Notifications**: Send email reminders for events
4. **Mobile App**: Dedicated mobile calendar view
5. **Time Zone Support**: Better handling of events across time zones
6. **Event Templates**: Create reusable event templates
7. **Attendees**: Add multiple attendees to events
8. **Conflict Detection**: Warn about overlapping events
## Support
For issues or feature requests related to the calendar:
1. Check this documentation first
2. Review the test files for examples
3. Check the GitHub issues for known problems
4. Contact your system administrator
## Version History
- **Version 1.0** (2025-10-27): Initial calendar/agenda support
- Day, week, and month views
- Event CRUD operations
- Integration with tasks and time entries
- Recurring event support
- API endpoints for all operations
## Related Documentation
- [TimeTracker User Guide](README.md)
- [API Documentation](API_DOCUMENTATION.md)
- [Task Management Guide](TASK_MANAGEMENT.md)
- [Project Management Guide](PROJECT_MANAGEMENT.md)

View File

@@ -0,0 +1,67 @@
"""Add calendar_events table for agenda/calendar support
Revision ID: 034_add_calendar_events
Revises: 033_add_email_settings
Create Date: 2025-10-27
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '034_add_calendar_events'
down_revision = '033_add_email_settings'
branch_labels = None
depends_on = None
def upgrade():
"""Create calendar_events table"""
op.create_table(
'calendar_events',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=200), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('start_time', sa.DateTime(), nullable=False),
sa.Column('end_time', sa.DateTime(), nullable=False),
sa.Column('all_day', sa.Boolean(), nullable=False, server_default='0'),
sa.Column('location', sa.String(length=200), nullable=True),
sa.Column('event_type', sa.String(length=50), nullable=False, server_default='event'),
sa.Column('project_id', sa.Integer(), nullable=True),
sa.Column('task_id', sa.Integer(), nullable=True),
sa.Column('client_id', sa.Integer(), nullable=True),
sa.Column('is_recurring', sa.Boolean(), nullable=False, server_default='0'),
sa.Column('recurrence_rule', sa.String(length=200), nullable=True),
sa.Column('recurrence_end_date', sa.DateTime(), nullable=True),
sa.Column('parent_event_id', sa.Integer(), nullable=True),
sa.Column('reminder_minutes', sa.Integer(), nullable=True),
sa.Column('color', sa.String(length=7), nullable=True),
sa.Column('is_private', sa.Boolean(), nullable=False, server_default='0'),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name='fk_calendar_events_user_id'),
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], name='fk_calendar_events_project_id'),
sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], name='fk_calendar_events_task_id'),
sa.ForeignKeyConstraint(['client_id'], ['clients.id'], name='fk_calendar_events_client_id'),
sa.ForeignKeyConstraint(['parent_event_id'], ['calendar_events.id'], name='fk_calendar_events_parent_event_id'),
sa.PrimaryKeyConstraint('id')
)
# Create indexes for better query performance
with op.batch_alter_table('calendar_events', schema=None) as batch_op:
batch_op.create_index('ix_calendar_events_user_id', ['user_id'])
batch_op.create_index('ix_calendar_events_start_time', ['start_time'])
batch_op.create_index('ix_calendar_events_end_time', ['end_time'])
batch_op.create_index('ix_calendar_events_event_type', ['event_type'])
batch_op.create_index('ix_calendar_events_project_id', ['project_id'])
batch_op.create_index('ix_calendar_events_task_id', ['task_id'])
batch_op.create_index('ix_calendar_events_client_id', ['client_id'])
batch_op.create_index('ix_calendar_events_parent_event_id', ['parent_event_id'])
def downgrade():
"""Drop calendar_events table"""
op.drop_table('calendar_events')

View File

@@ -0,0 +1,667 @@
"""
Test suite for CalendarEvent model.
Tests model creation, relationships, properties, and business logic.
"""
import pytest
from datetime import datetime, timedelta
from app.models import CalendarEvent, User, Project, Task, Client, TimeEntry
from app import db
# ============================================================================
# CalendarEvent Model Tests
# ============================================================================
@pytest.mark.unit
@pytest.mark.models
@pytest.mark.smoke
def test_calendar_event_creation(app, user, project):
"""Test basic calendar event creation."""
with app.app_context():
start_time = datetime.now()
end_time = start_time + timedelta(hours=2)
event = CalendarEvent(
user_id=user.id,
title="Team Meeting",
start_time=start_time,
end_time=end_time,
description="Weekly team sync",
location="Conference Room A",
event_type="meeting"
)
db.session.add(event)
db.session.commit()
assert event.id is not None
assert event.title == "Team Meeting"
assert event.user_id == user.id
assert event.start_time == start_time
assert event.end_time == end_time
assert event.description == "Weekly team sync"
assert event.location == "Conference Room A"
assert event.event_type == "meeting"
assert event.all_day is False
assert event.is_private is False
assert event.is_recurring is False
assert event.created_at is not None
assert event.updated_at is not None
@pytest.mark.unit
@pytest.mark.models
def test_calendar_event_all_day(app, user):
"""Test all-day calendar event."""
with app.app_context():
start_time = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
end_time = start_time.replace(hour=23, minute=59, second=59)
event = CalendarEvent(
user_id=user.id,
title="Holiday",
start_time=start_time,
end_time=end_time,
all_day=True,
event_type="event"
)
db.session.add(event)
db.session.commit()
assert event.all_day is True
@pytest.mark.unit
@pytest.mark.models
def test_calendar_event_with_project(app, user, project):
"""Test calendar event associated with a project."""
with app.app_context():
start_time = datetime.now()
end_time = start_time + timedelta(hours=1)
event = CalendarEvent(
user_id=user.id,
title="Project Review",
start_time=start_time,
end_time=end_time,
project_id=project.id,
event_type="meeting"
)
db.session.add(event)
db.session.commit()
db.session.refresh(event)
assert event.project is not None
assert event.project.id == project.id
assert event.project.name == project.name
@pytest.mark.unit
@pytest.mark.models
def test_calendar_event_with_task(app, user, project):
"""Test calendar event associated with a task."""
with app.app_context():
# Create a task
task = Task(
project_id=project.id,
name="Complete documentation",
created_by=user.id,
assigned_to=user.id
)
db.session.add(task)
db.session.commit()
start_time = datetime.now()
end_time = start_time + timedelta(hours=3)
event = CalendarEvent(
user_id=user.id,
title="Work on documentation",
start_time=start_time,
end_time=end_time,
task_id=task.id,
event_type="deadline"
)
db.session.add(event)
db.session.commit()
db.session.refresh(event)
assert event.task is not None
assert event.task.id == task.id
assert event.task.name == "Complete documentation"
@pytest.mark.unit
@pytest.mark.models
def test_calendar_event_with_client(app, user, test_client):
"""Test calendar event associated with a client."""
with app.app_context():
start_time = datetime.now()
end_time = start_time + timedelta(hours=1)
event = CalendarEvent(
user_id=user.id,
title="Client Meeting",
start_time=start_time,
end_time=end_time,
client_id=test_client.id,
event_type="appointment"
)
db.session.add(event)
db.session.commit()
db.session.refresh(event)
assert event.client is not None
assert event.client.id == test_client.id
@pytest.mark.unit
@pytest.mark.models
def test_calendar_event_recurring(app, user):
"""Test recurring calendar event."""
with app.app_context():
start_time = datetime.now()
end_time = start_time + timedelta(hours=1)
recurrence_end = start_time + timedelta(days=90)
event = CalendarEvent(
user_id=user.id,
title="Weekly Standup",
start_time=start_time,
end_time=end_time,
is_recurring=True,
recurrence_rule="FREQ=WEEKLY;BYDAY=MO,WE,FR",
recurrence_end_date=recurrence_end,
event_type="meeting"
)
db.session.add(event)
db.session.commit()
assert event.is_recurring is True
assert event.recurrence_rule == "FREQ=WEEKLY;BYDAY=MO,WE,FR"
assert event.recurrence_end_date == recurrence_end
@pytest.mark.unit
@pytest.mark.models
def test_calendar_event_with_reminder(app, user):
"""Test calendar event with reminder."""
with app.app_context():
start_time = datetime.now()
end_time = start_time + timedelta(hours=1)
event = CalendarEvent(
user_id=user.id,
title="Important Meeting",
start_time=start_time,
end_time=end_time,
reminder_minutes=30,
event_type="meeting"
)
db.session.add(event)
db.session.commit()
assert event.reminder_minutes == 30
@pytest.mark.unit
@pytest.mark.models
def test_calendar_event_with_color(app, user):
"""Test calendar event with custom color."""
with app.app_context():
start_time = datetime.now()
end_time = start_time + timedelta(hours=1)
event = CalendarEvent(
user_id=user.id,
title="Colored Event",
start_time=start_time,
end_time=end_time,
color="#FF5733",
event_type="event"
)
db.session.add(event)
db.session.commit()
assert event.color == "#FF5733"
@pytest.mark.unit
@pytest.mark.models
def test_calendar_event_private(app, user):
"""Test private calendar event."""
with app.app_context():
start_time = datetime.now()
end_time = start_time + timedelta(hours=1)
event = CalendarEvent(
user_id=user.id,
title="Private Event",
start_time=start_time,
end_time=end_time,
is_private=True,
event_type="event"
)
db.session.add(event)
db.session.commit()
assert event.is_private is True
@pytest.mark.unit
@pytest.mark.models
def test_calendar_event_duration_hours(app, user):
"""Test calendar event duration calculation."""
with app.app_context():
start_time = datetime.now()
end_time = start_time + timedelta(hours=2, minutes=30)
event = CalendarEvent(
user_id=user.id,
title="Test Event",
start_time=start_time,
end_time=end_time,
event_type="event"
)
db.session.add(event)
db.session.commit()
assert event.duration_hours() == 2.5
@pytest.mark.unit
@pytest.mark.models
def test_calendar_event_to_dict(app, user, project):
"""Test calendar event serialization to dictionary."""
with app.app_context():
start_time = datetime.now()
end_time = start_time + timedelta(hours=1)
event = CalendarEvent(
user_id=user.id,
title="Test Event",
start_time=start_time,
end_time=end_time,
description="Test description",
location="Office",
event_type="meeting",
project_id=project.id,
all_day=False,
is_private=False,
color="#3b82f6",
reminder_minutes=15
)
db.session.add(event)
db.session.commit()
event_dict = event.to_dict()
assert 'id' in event_dict
assert 'title' in event_dict
assert 'description' in event_dict
assert 'start' in event_dict
assert 'end' in event_dict
assert 'allDay' in event_dict
assert 'location' in event_dict
assert 'eventType' in event_dict
assert 'projectId' in event_dict
assert 'color' in event_dict
assert 'isPrivate' in event_dict
assert 'reminderMinutes' in event_dict
assert event_dict['title'] == "Test Event"
assert event_dict['description'] == "Test description"
assert event_dict['location'] == "Office"
assert event_dict['eventType'] == "meeting"
assert event_dict['projectId'] == project.id
assert event_dict['color'] == "#3b82f6"
assert event_dict['reminderMinutes'] == 15
@pytest.mark.unit
@pytest.mark.models
def test_calendar_event_user_relationship(app, user):
"""Test calendar event user relationship."""
with app.app_context():
start_time = datetime.now()
end_time = start_time + timedelta(hours=1)
event = CalendarEvent(
user_id=user.id,
title="Test Event",
start_time=start_time,
end_time=end_time,
event_type="event"
)
db.session.add(event)
db.session.commit()
db.session.refresh(event)
assert event.user is not None
assert event.user.id == user.id
assert event.user.username == user.username
@pytest.mark.unit
@pytest.mark.models
def test_calendar_event_parent_child_relationship(app, user):
"""Test recurring calendar event parent-child relationship."""
with app.app_context():
start_time = datetime.now()
end_time = start_time + timedelta(hours=1)
# Create parent event
parent_event = CalendarEvent(
user_id=user.id,
title="Recurring Meeting",
start_time=start_time,
end_time=end_time,
is_recurring=True,
recurrence_rule="FREQ=WEEKLY",
event_type="meeting"
)
db.session.add(parent_event)
db.session.commit()
# Create child event (instance of recurring event)
child_start = start_time + timedelta(days=7)
child_end = child_start + timedelta(hours=1)
child_event = CalendarEvent(
user_id=user.id,
title="Recurring Meeting",
start_time=child_start,
end_time=child_end,
parent_event_id=parent_event.id,
event_type="meeting"
)
db.session.add(child_event)
db.session.commit()
db.session.refresh(parent_event)
db.session.refresh(child_event)
assert child_event.parent_event is not None
assert child_event.parent_event.id == parent_event.id
assert parent_event.child_events.count() == 1
@pytest.mark.unit
@pytest.mark.models
def test_get_events_in_range(app, user):
"""Test getting events in a date range."""
with app.app_context():
# Create events
now = datetime.now()
# Event within range
event1 = CalendarEvent(
user_id=user.id,
title="Event 1",
start_time=now,
end_time=now + timedelta(hours=1),
event_type="event"
)
# Event outside range
event2 = CalendarEvent(
user_id=user.id,
title="Event 2",
start_time=now + timedelta(days=30),
end_time=now + timedelta(days=30, hours=1),
event_type="event"
)
db.session.add_all([event1, event2])
db.session.commit()
# Get events in range
start_date = now - timedelta(days=1)
end_date = now + timedelta(days=7)
result = CalendarEvent.get_events_in_range(
user_id=user.id,
start_date=start_date,
end_date=end_date,
include_tasks=False,
include_time_entries=False
)
assert len(result['events']) == 1
assert result['events'][0]['title'] == "Event 1"
@pytest.mark.unit
@pytest.mark.models
def test_get_events_in_range_with_tasks(app, user, project):
"""Test getting events with tasks in date range."""
with app.app_context():
# Create event
now = datetime.now()
event = CalendarEvent(
user_id=user.id,
title="Event",
start_time=now,
end_time=now + timedelta(hours=1),
event_type="event"
)
db.session.add(event)
# Create task with due date
task = Task(
project_id=project.id,
name="Task with due date",
created_by=user.id,
assigned_to=user.id,
due_date=now.date() + timedelta(days=3),
status='todo'
)
db.session.add(task)
db.session.commit()
# Get events including tasks
start_date = now - timedelta(days=1)
end_date = now + timedelta(days=7)
result = CalendarEvent.get_events_in_range(
user_id=user.id,
start_date=start_date,
end_date=end_date,
include_tasks=True,
include_time_entries=False
)
assert len(result['events']) == 1
assert len(result['tasks']) == 1
assert result['tasks'][0]['title'] == "Task with due date"
@pytest.mark.unit
@pytest.mark.models
def test_get_events_in_range_with_time_entries(app, user, project):
"""Test getting events with time entries in date range."""
with app.app_context():
# Create event
now = datetime.now()
event = CalendarEvent(
user_id=user.id,
title="Event",
start_time=now,
end_time=now + timedelta(hours=1),
event_type="event"
)
db.session.add(event)
# Create time entry
time_entry = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=now + timedelta(hours=2),
end_time=now + timedelta(hours=4),
notes="Working on feature"
)
db.session.add(time_entry)
db.session.commit()
# Get events including time entries
start_date = now - timedelta(days=1)
end_date = now + timedelta(days=1)
result = CalendarEvent.get_events_in_range(
user_id=user.id,
start_date=start_date,
end_date=end_date,
include_tasks=False,
include_time_entries=True
)
assert len(result['events']) == 1
assert len(result['time_entries']) == 1
@pytest.mark.unit
@pytest.mark.models
def test_calendar_event_repr(app, user):
"""Test calendar event string representation."""
with app.app_context():
start_time = datetime.now()
end_time = start_time + timedelta(hours=1)
event = CalendarEvent(
user_id=user.id,
title="Test Event",
start_time=start_time,
end_time=end_time,
event_type="event"
)
db.session.add(event)
db.session.commit()
repr_str = repr(event)
assert 'CalendarEvent' in repr_str
assert 'Test Event' in repr_str
@pytest.mark.unit
@pytest.mark.models
def test_calendar_event_cascade_delete_with_user(app, user):
"""Test that events are deleted when user is deleted."""
with app.app_context():
start_time = datetime.now()
end_time = start_time + timedelta(hours=1)
event = CalendarEvent(
user_id=user.id,
title="Test Event",
start_time=start_time,
end_time=end_time,
event_type="event"
)
db.session.add(event)
db.session.commit()
event_id = event.id
# Delete user
db.session.delete(user)
db.session.commit()
# Event should be deleted
deleted_event = CalendarEvent.query.get(event_id)
assert deleted_event is None
@pytest.mark.unit
@pytest.mark.models
def test_calendar_event_cascade_delete_with_parent(app, user):
"""Test that child events are deleted when parent is deleted."""
with app.app_context():
start_time = datetime.now()
end_time = start_time + timedelta(hours=1)
# Create parent event
parent_event = CalendarEvent(
user_id=user.id,
title="Parent Event",
start_time=start_time,
end_time=end_time,
is_recurring=True,
event_type="meeting"
)
db.session.add(parent_event)
db.session.commit()
# Create child event
child_start = start_time + timedelta(days=7)
child_end = child_start + timedelta(hours=1)
child_event = CalendarEvent(
user_id=user.id,
title="Child Event",
start_time=child_start,
end_time=child_end,
parent_event_id=parent_event.id,
event_type="meeting"
)
db.session.add(child_event)
db.session.commit()
child_id = child_event.id
# Delete parent
db.session.delete(parent_event)
db.session.commit()
# Child should be deleted
deleted_child = CalendarEvent.query.get(child_id)
assert deleted_child is None
@pytest.mark.unit
@pytest.mark.models
def test_calendar_event_different_types(app, user):
"""Test calendar events with different types."""
with app.app_context():
now = datetime.now()
event_types = ['event', 'meeting', 'appointment', 'reminder', 'deadline']
events = []
for event_type in event_types:
event = CalendarEvent(
user_id=user.id,
title=f"Test {event_type}",
start_time=now,
end_time=now + timedelta(hours=1),
event_type=event_type
)
events.append(event)
db.session.add_all(events)
db.session.commit()
for idx, event_type in enumerate(event_types):
assert events[idx].event_type == event_type
@pytest.mark.unit
@pytest.mark.models
def test_calendar_event_user_has_events_relationship(app, user):
"""Test that user has calendar_events relationship."""
with app.app_context():
now = datetime.now()
event1 = CalendarEvent(
user_id=user.id,
title="Event 1",
start_time=now,
end_time=now + timedelta(hours=1),
event_type="event"
)
event2 = CalendarEvent(
user_id=user.id,
title="Event 2",
start_time=now + timedelta(days=1),
end_time=now + timedelta(days=1, hours=1),
event_type="meeting"
)
db.session.add_all([event1, event2])
db.session.commit()
db.session.refresh(user)
assert user.calendar_events.count() == 2

View File

@@ -0,0 +1,585 @@
"""
Test suite for calendar routes and endpoints.
Tests calendar views, event CRUD operations, and API endpoints.
"""
import pytest
import json
from datetime import datetime, timedelta
from app.models import CalendarEvent, Task
from app import db
# ============================================================================
# Calendar View Routes
# ============================================================================
@pytest.mark.smoke
@pytest.mark.routes
def test_calendar_view_accessible(authenticated_client):
"""Test that calendar view is accessible for authenticated users."""
response = authenticated_client.get('/calendar')
assert response.status_code == 200
assert b'Calendar' in response.data or b'calendar' in response.data
@pytest.mark.routes
def test_calendar_view_requires_authentication(client):
"""Test that calendar view requires authentication."""
response = client.get('/calendar', follow_redirects=False)
assert response.status_code == 302
assert '/login' in response.location or 'login' in response.location.lower()
@pytest.mark.routes
def test_calendar_day_view(authenticated_client):
"""Test calendar day view."""
response = authenticated_client.get('/calendar?view=day')
assert response.status_code == 200
@pytest.mark.routes
def test_calendar_week_view(authenticated_client):
"""Test calendar week view."""
response = authenticated_client.get('/calendar?view=week')
assert response.status_code == 200
@pytest.mark.routes
def test_calendar_month_view(authenticated_client):
"""Test calendar month view."""
response = authenticated_client.get('/calendar?view=month')
assert response.status_code == 200
@pytest.mark.routes
def test_calendar_with_date_parameter(authenticated_client):
"""Test calendar view with specific date."""
test_date = '2025-01-15'
response = authenticated_client.get(f'/calendar?date={test_date}')
assert response.status_code == 200
# ============================================================================
# Calendar Event API Endpoints
# ============================================================================
@pytest.mark.api
@pytest.mark.routes
def test_get_calendar_events_api(authenticated_client, user, app):
"""Test getting calendar events via API."""
with app.app_context():
# Create test event
start_time = datetime.now()
end_time = start_time + timedelta(hours=2)
event = CalendarEvent(
user_id=user.id,
title="Test Event",
start_time=start_time,
end_time=end_time,
event_type="meeting"
)
db.session.add(event)
db.session.commit()
# Query events
start_str = (start_time - timedelta(days=1)).isoformat()
end_str = (end_time + timedelta(days=1)).isoformat()
response = authenticated_client.get(
f'/api/calendar/events?start={start_str}&end={end_str}'
)
assert response.status_code == 200
data = response.get_json()
assert 'events' in data
assert len(data['events']) > 0
assert data['events'][0]['title'] == "Test Event"
@pytest.mark.api
@pytest.mark.routes
def test_get_calendar_events_missing_dates(authenticated_client):
"""Test getting events without required date parameters."""
response = authenticated_client.get('/api/calendar/events')
assert response.status_code == 400
data = response.get_json()
assert 'error' in data
@pytest.mark.api
@pytest.mark.routes
def test_create_calendar_event_api(authenticated_client, app):
"""Test creating a calendar event via API."""
with app.app_context():
start_time = datetime.now()
end_time = start_time + timedelta(hours=1)
event_data = {
'title': 'New Meeting',
'description': 'Team sync',
'start': start_time.isoformat(),
'end': end_time.isoformat(),
'allDay': False,
'location': 'Office',
'eventType': 'meeting'
}
response = authenticated_client.post(
'/api/calendar/events',
data=json.dumps(event_data),
content_type='application/json'
)
assert response.status_code == 201
data = response.get_json()
assert data['success'] is True
assert 'event' in data
assert data['event']['title'] == 'New Meeting'
# Verify event was created in database
event = CalendarEvent.query.filter_by(title='New Meeting').first()
assert event is not None
assert event.description == 'Team sync'
@pytest.mark.api
@pytest.mark.routes
def test_create_calendar_event_missing_required_fields(authenticated_client):
"""Test creating event without required fields."""
event_data = {
'description': 'Missing title'
}
response = authenticated_client.post(
'/api/calendar/events',
data=json.dumps(event_data),
content_type='application/json'
)
assert response.status_code == 400
data = response.get_json()
assert 'error' in data
@pytest.mark.api
@pytest.mark.routes
def test_get_single_event_api(authenticated_client, user, app):
"""Test getting a single calendar event."""
with app.app_context():
start_time = datetime.now()
end_time = start_time + timedelta(hours=1)
event = CalendarEvent(
user_id=user.id,
title="Test Event",
start_time=start_time,
end_time=end_time,
event_type="event"
)
db.session.add(event)
db.session.commit()
event_id = event.id
response = authenticated_client.get(f'/api/calendar/events/{event_id}')
assert response.status_code == 200
data = response.get_json()
assert data['title'] == "Test Event"
@pytest.mark.api
@pytest.mark.routes
def test_get_nonexistent_event(authenticated_client):
"""Test getting a non-existent event."""
response = authenticated_client.get('/api/calendar/events/99999')
assert response.status_code == 404
@pytest.mark.api
@pytest.mark.routes
def test_update_calendar_event_api(authenticated_client, user, app):
"""Test updating a calendar event via API."""
with app.app_context():
start_time = datetime.now()
end_time = start_time + timedelta(hours=1)
event = CalendarEvent(
user_id=user.id,
title="Original Title",
start_time=start_time,
end_time=end_time,
event_type="event"
)
db.session.add(event)
db.session.commit()
event_id = event.id
update_data = {
'title': 'Updated Title',
'description': 'Updated description'
}
response = authenticated_client.put(
f'/api/calendar/events/{event_id}',
data=json.dumps(update_data),
content_type='application/json'
)
assert response.status_code == 200
data = response.get_json()
assert data['success'] is True
assert data['event']['title'] == 'Updated Title'
# Verify in database
db.session.refresh(event)
assert event.title == 'Updated Title'
assert event.description == 'Updated description'
@pytest.mark.api
@pytest.mark.routes
def test_update_event_permission_denied(authenticated_client, admin_user, app):
"""Test that users cannot update other users' events."""
with app.app_context():
# Create event for admin user
start_time = datetime.now()
end_time = start_time + timedelta(hours=1)
event = CalendarEvent(
user_id=admin_user.id,
title="Admin Event",
start_time=start_time,
end_time=end_time,
event_type="event"
)
db.session.add(event)
db.session.commit()
event_id = event.id
# Try to update as regular user
update_data = {'title': 'Hacked Title'}
response = authenticated_client.put(
f'/api/calendar/events/{event_id}',
data=json.dumps(update_data),
content_type='application/json'
)
assert response.status_code == 403
@pytest.mark.api
@pytest.mark.routes
def test_delete_calendar_event_api(authenticated_client, user, app):
"""Test deleting a calendar event via API."""
with app.app_context():
start_time = datetime.now()
end_time = start_time + timedelta(hours=1)
event = CalendarEvent(
user_id=user.id,
title="Event to Delete",
start_time=start_time,
end_time=end_time,
event_type="event"
)
db.session.add(event)
db.session.commit()
event_id = event.id
response = authenticated_client.delete(f'/api/calendar/events/{event_id}')
assert response.status_code == 200
data = response.get_json()
assert data['success'] is True
# Verify deletion in database
deleted_event = CalendarEvent.query.get(event_id)
assert deleted_event is None
@pytest.mark.api
@pytest.mark.routes
def test_delete_event_permission_denied(authenticated_client, admin_user, app):
"""Test that users cannot delete other users' events."""
with app.app_context():
# Create event for admin user
start_time = datetime.now()
end_time = start_time + timedelta(hours=1)
event = CalendarEvent(
user_id=admin_user.id,
title="Admin Event",
start_time=start_time,
end_time=end_time,
event_type="event"
)
db.session.add(event)
db.session.commit()
event_id = event.id
# Try to delete as regular user
response = authenticated_client.delete(f'/api/calendar/events/{event_id}')
assert response.status_code == 403
@pytest.mark.api
@pytest.mark.routes
def test_move_calendar_event_api(authenticated_client, user, app):
"""Test moving a calendar event (drag and drop)."""
with app.app_context():
start_time = datetime.now()
end_time = start_time + timedelta(hours=1)
event = CalendarEvent(
user_id=user.id,
title="Event to Move",
start_time=start_time,
end_time=end_time,
event_type="event"
)
db.session.add(event)
db.session.commit()
event_id = event.id
new_start = start_time + timedelta(days=1)
new_end = end_time + timedelta(days=1)
move_data = {
'start': new_start.isoformat(),
'end': new_end.isoformat()
}
response = authenticated_client.post(
f'/api/calendar/events/{event_id}/move',
data=json.dumps(move_data),
content_type='application/json'
)
assert response.status_code == 200
data = response.get_json()
assert data['success'] is True
# Verify in database
db.session.refresh(event)
assert event.start_time.date() == new_start.date()
@pytest.mark.api
@pytest.mark.routes
def test_resize_calendar_event_api(authenticated_client, user, app):
"""Test resizing a calendar event."""
with app.app_context():
start_time = datetime.now()
end_time = start_time + timedelta(hours=1)
event = CalendarEvent(
user_id=user.id,
title="Event to Resize",
start_time=start_time,
end_time=end_time,
event_type="event"
)
db.session.add(event)
db.session.commit()
event_id = event.id
new_end = end_time + timedelta(hours=1)
resize_data = {
'end': new_end.isoformat()
}
response = authenticated_client.post(
f'/api/calendar/events/{event_id}/resize',
data=json.dumps(resize_data),
content_type='application/json'
)
assert response.status_code == 200
data = response.get_json()
assert data['success'] is True
# Verify duration changed
db.session.refresh(event)
assert event.duration_hours() == 2.0
# ============================================================================
# Calendar Event Form Routes
# ============================================================================
@pytest.mark.routes
def test_new_event_form_accessible(authenticated_client):
"""Test that new event form is accessible."""
response = authenticated_client.get('/calendar/event/new')
assert response.status_code == 200
assert b'New Event' in response.data or b'new event' in response.data.lower()
@pytest.mark.routes
def test_edit_event_form_accessible(authenticated_client, user, app):
"""Test that edit event form is accessible."""
with app.app_context():
start_time = datetime.now()
end_time = start_time + timedelta(hours=1)
event = CalendarEvent(
user_id=user.id,
title="Test Event",
start_time=start_time,
end_time=end_time,
event_type="event"
)
db.session.add(event)
db.session.commit()
event_id = event.id
response = authenticated_client.get(f'/calendar/event/{event_id}/edit')
assert response.status_code == 200
assert b'Edit' in response.data or b'edit' in response.data.lower()
@pytest.mark.routes
def test_edit_event_form_permission_denied(authenticated_client, admin_user, app):
"""Test that users cannot access edit form for other users' events."""
with app.app_context():
start_time = datetime.now()
end_time = start_time + timedelta(hours=1)
event = CalendarEvent(
user_id=admin_user.id,
title="Admin Event",
start_time=start_time,
end_time=end_time,
event_type="event"
)
db.session.add(event)
db.session.commit()
event_id = event.id
response = authenticated_client.get(
f'/calendar/event/{event_id}/edit',
follow_redirects=False
)
assert response.status_code == 302 # Redirected
@pytest.mark.routes
def test_view_event_detail(authenticated_client, user, app):
"""Test viewing event detail page."""
with app.app_context():
start_time = datetime.now()
end_time = start_time + timedelta(hours=1)
event = CalendarEvent(
user_id=user.id,
title="Test Event",
start_time=start_time,
end_time=end_time,
description="Test description",
location="Test location",
event_type="meeting"
)
db.session.add(event)
db.session.commit()
event_id = event.id
response = authenticated_client.get(f'/calendar/event/{event_id}')
assert response.status_code == 200
assert b'Test Event' in response.data
assert b'Test description' in response.data
# ============================================================================
# Calendar Integration Tests
# ============================================================================
@pytest.mark.integration
@pytest.mark.routes
def test_calendar_shows_tasks(authenticated_client, user, project, app):
"""Test that calendar includes tasks with due dates."""
with app.app_context():
# Create task with due date
task = Task(
project_id=project.id,
name="Task with due date",
created_by=user.id,
assigned_to=user.id,
due_date=datetime.now().date() + timedelta(days=3),
status='todo'
)
db.session.add(task)
db.session.commit()
# Query calendar events API
start_str = datetime.now().isoformat()
end_str = (datetime.now() + timedelta(days=7)).isoformat()
response = authenticated_client.get(
f'/api/calendar/events?start={start_str}&end={end_str}&include_tasks=true'
)
assert response.status_code == 200
data = response.get_json()
assert 'tasks' in data
assert len(data['tasks']) > 0
@pytest.mark.integration
@pytest.mark.routes
def test_calendar_with_project_filter(authenticated_client, user, project, app):
"""Test creating event with project association."""
with app.app_context():
start_time = datetime.now()
end_time = start_time + timedelta(hours=1)
event_data = {
'title': 'Project Meeting',
'start': start_time.isoformat(),
'end': end_time.isoformat(),
'projectId': project.id,
'eventType': 'meeting'
}
response = authenticated_client.post(
'/api/calendar/events',
data=json.dumps(event_data),
content_type='application/json'
)
assert response.status_code == 201
data = response.get_json()
assert data['event']['projectId'] == project.id
@pytest.mark.smoke
@pytest.mark.routes
def test_calendar_event_creation_workflow(authenticated_client, user, app):
"""Test complete workflow of creating and viewing an event."""
with app.app_context():
# Create event
start_time = datetime.now()
end_time = start_time + timedelta(hours=2)
event_data = {
'title': 'Complete Workflow Test',
'description': 'Testing full workflow',
'start': start_time.isoformat(),
'end': end_time.isoformat(),
'location': 'Test Location',
'eventType': 'meeting',
'color': '#3b82f6',
'reminderMinutes': 30
}
# Create via API
response = authenticated_client.post(
'/api/calendar/events',
data=json.dumps(event_data),
content_type='application/json'
)
assert response.status_code == 201
event_id = response.get_json()['event']['id']
# Retrieve via API
response = authenticated_client.get(f'/api/calendar/events/{event_id}')
assert response.status_code == 200
event = response.get_json()
assert event['title'] == 'Complete Workflow Test'
assert event['reminderMinutes'] == 30
# View detail page
response = authenticated_client.get(f'/calendar/event/{event_id}')
assert response.status_code == 200
assert b'Complete Workflow Test' in response.data