From 154f9b37a6845a9600ab832b099b6c69eecc360a Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Mon, 27 Oct 2025 11:40:38 +0100 Subject: [PATCH 1/2] feat: integrate calendar feature with tasks and time entries display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactored the existing calendar API endpoint to properly display calendar events, tasks, and time entries with distinct visual representations. Changes: - Updated /api/calendar/events endpoint in api.py to use new CalendarEvent.get_events_in_range() method that fetches all three item types - Fixed user_id bug where it was defaulting to None instead of current_user.id - Modified API response format to include all items in unified 'events' array with item_type field ('event', 'task', 'time_entry') for differentiation - Updated calendar.js to parse unified response format and filter items by type - Added visual distinctions: * Tasks: 📋 emoji, orange (#f59e0b) color, clickable * Time entries: ⏱ emoji, project-based colors, non-clickable * Calendar events: 📅 emoji, custom colors, clickable - Fixed task detail route from /tasks/view/{id} to /tasks/{id} - Updated all calendar view renderers (month, week, day) to use correct data structure with extendedProps - Added cache-busting to calendar.js (v7) and calendar.css (v2) - Preserved backward compatibility with existing calendar filtering (project_id, task_id, tags) The calendar now correctly displays all time tracking data in a unified view with proper visual hierarchy and interaction patterns. Fixes: Calendar not showing tasks and time entries Related: Calendar/Agenda Support feature implementation --- app/__init__.py | 2 + app/models/__init__.py | 2 + app/models/calendar_event.py | 227 ++++++ app/routes/api.py | 140 ++-- app/routes/calendar.py | 415 +++++++++++ app/static/calendar.css | 472 +++++++++++++ app/static/calendar.js | 527 ++++++++++++++ app/templates/base.html | 6 + app/templates/calendar/event_detail.html | 186 +++++ app/templates/calendar/event_form.html | 316 +++++++++ app/templates/calendar/view.html | 139 ++++ docs/CALENDAR_AGENDA_FEATURE.md | 446 ++++++++++++ .../versions/034_add_calendar_events_table.py | 67 ++ tests/test_calendar_event_model.py | 667 ++++++++++++++++++ tests/test_calendar_routes.py | 585 +++++++++++++++ 15 files changed, 4144 insertions(+), 53 deletions(-) create mode 100644 app/models/calendar_event.py create mode 100644 app/routes/calendar.py create mode 100644 app/static/calendar.css create mode 100644 app/static/calendar.js create mode 100644 app/templates/calendar/event_detail.html create mode 100644 app/templates/calendar/event_form.html create mode 100644 app/templates/calendar/view.html create mode 100644 docs/CALENDAR_AGENDA_FEATURE.md create mode 100644 migrations/versions/034_add_calendar_events_table.py create mode 100644 tests/test_calendar_event_model.py create mode 100644 tests/test_calendar_routes.py diff --git a/app/__init__.py b/app/__init__.py index f0c5bce..1268337 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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 diff --git a/app/models/__init__.py b/app/models/__init__.py index c032e6e..2ab7760 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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", ] diff --git a/app/models/calendar_event.py b/app/models/calendar_event.py new file mode 100644 index 0000000..1c6d2b4 --- /dev/null +++ b/app/models/calendar_event.py @@ -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'' + + 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 + diff --git a/app/routes/api.py b/app/routes/api.py index 49b1c60..fb9de7b 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -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 diff --git a/app/routes/calendar.py b/app/routes/calendar.py new file mode 100644 index 0000000..ed10380 --- /dev/null +++ b/app/routes/calendar.py @@ -0,0 +1,415 @@ +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/', 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/', 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/', methods=['DELETE']) +@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: + return jsonify({'error': 'Permission denied'}), 403 + + try: + db.session.delete(event) + if not safe_commit(): + return jsonify({'error': 'Failed to delete event'}), 500 + + return jsonify({ + 'success': True, + 'message': _('Event deleted successfully') + }) + + except Exception as e: + db.session.rollback() + return jsonify({'error': f'Error deleting event: {str(e)}'}), 500 + + +@calendar_bp.route('/api/calendar/events//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//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/') +@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//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 + ) + diff --git a/app/static/calendar.css b/app/static/calendar.css new file mode 100644 index 0000000..8d29e65 --- /dev/null +++ b/app/static/calendar.css @@ -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; +} + diff --git a/app/static/calendar.js b/app/static/calendar.js new file mode 100644 index 0000000..0496969 --- /dev/null +++ b/app/static/calendar.js @@ -0,0 +1,527 @@ +/** + * 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 = '
Error loading calendar data
'; + } + } + + 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 = ` +
+
+ ${this.renderTimeSlots()} +
+
+ ${this.renderDayEvents()} +
+
+ `; + this.container.innerHTML = html; + } + + renderTimeSlots() { + const slots = []; + for (let hour = 0; hour < 24; hour++) { + const time = `${hour.toString().padStart(2, '0')}:00`; + slots.push(`
${time}
`); + } + 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 = '
'; + + // 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 += ` +
+ + ${eventTitle} +
${time} +
+ `; + } + }); + } + + // 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 += ` +
+ ${priorityIcon} ${taskTitle} +
Due: ${task.start} +
Status: ${task.extendedProps?.status || 'Unknown'} +
+ `; + }); + } + + // 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 ? `
${this.escapeHtml(entry.notes)}` : ''; + html += ` +
+ ⏱ ${entryTitle} +
${startTime} + ${notes} +
+ `; + } + }); + } + + html += '
'; + 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 = '
'; + + days.forEach(day => { + const isToday = this.isToday(day); + html += ``; + }); + + html += ''; + + // Time slots for each day + for (let hour = 0; hour < 24; hour++) { + html += ''; + days.forEach(day => { + html += `'; + }); + html += ''; + } + + html += '
${day.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })}
`; + html += this.renderWeekCellEvents(day, hour); + html += '
'; + 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 += `
📅 ${eventTitle}
`; + } + }); + } + + // 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 += `
📋 ${taskTitle}
`; + } + }); + } + + // 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 += `
⏱ ${entryTitle}
`; + } + }); + } + + 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 = '
'; + const weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + weekdays.forEach(day => { + html += ``; + }); + html += ''; + + const currentDate = new Date(startDate); + for (let week = 0; week < 6; week++) { + html += ''; + for (let day = 0; day < 7; day++) { + const isCurrentMonth = currentDate.getMonth() === month; + const isToday = this.isToday(currentDate); + html += `'; + currentDate.setDate(currentDate.getDate() + 1); + } + html += ''; + } + + html += '
${day}
`; + html += `
${currentDate.getDate()}
`; + html += this.renderMonthCellEvents(currentDate); + html += '
'; + 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 = '
'; + 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 += `
📅 ${eventTitle}
`; + } + 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 += `
📋 ${taskTitle}
`; + } + 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 += `
⏱ ${entryTitle}
`; + } + count++; + } + }); + } + + if (count > maxDisplay) { + html += `
+${count - maxDisplay} more
`; + } + + html += '
'; + 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) { + 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 = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + 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 + }); + } +}); + diff --git a/app/templates/base.html b/app/templates/base.html index 866cc37..2126640 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -122,6 +122,12 @@ {{ _('Weekly Goals') }} +
  • + + + {{ _('Calendar') }} + +
  • + + + + + +
    + +
    + +
    +

    {{ _('Date & Time') }}

    +

    + {% 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 %} + All Day + {% 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 %} +

    +

    {{ _('Duration') }}: {{ '%.2f'|format(event.duration_hours()) }} hours

    +
    +
    + + + {% if event.description %} +
    + +
    +

    {{ _('Description') }}

    +

    {{ event.description }}

    +
    +
    + {% endif %} + + + {% if event.location %} +
    + +
    +

    {{ _('Location') }}

    +

    {{ event.location }}

    +
    +
    + {% endif %} + + + {% if event.project %} +
    + +
    +

    {{ _('Project') }}

    +

    + + {{ event.project.name }} + +

    +
    +
    + {% endif %} + + + {% if event.task %} +
    + +
    +

    {{ _('Task') }}

    +

    + + {{ event.task.name }} + +

    +
    +
    + {% endif %} + + + {% if event.client %} +
    + +
    +

    {{ _('Client') }}

    +

    + + {{ event.client.name }} + +

    +
    +
    + {% endif %} + + + {% if event.reminder_minutes %} +
    + +
    +

    {{ _('Reminder') }}

    +

    + {% 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 %} +

    +
    +
    + {% endif %} + + + {% if event.is_recurring %} +
    + +
    +

    {{ _('Recurring') }}

    +

    + {% if event.recurrence_rule %}{{ event.recurrence_rule }}{% else %}Yes{% endif %} + {% if event.recurrence_end_date %} +
    {{ _('Until') }}: {{ event.recurrence_end_date.strftime('%B %d, %Y') }} + {% endif %} +

    +
    +
    + {% endif %} + + +
    + +
    +

    {{ _('Information') }}

    +

    + {{ _('Created') }}: {{ event.created_at.strftime('%B %d, %Y at %I:%M %p') }}
    + {{ _('Last Updated') }}: {{ event.updated_at.strftime('%B %d, %Y at %I:%M %p') }} +

    +
    +
    +
    + + + + + +{% endblock %} + diff --git a/app/templates/calendar/event_form.html b/app/templates/calendar/event_form.html new file mode 100644 index 0000000..c12a48f --- /dev/null +++ b/app/templates/calendar/event_form.html @@ -0,0 +1,316 @@ +{% extends "base.html" %} +{% block title %}{% if edit_mode %}{{ _('Edit Event') }}{% else %}{{ _('New Event') }}{% endif %} - {{ app_name }}{% endblock %} + +{% block content %} +
    +
    +
    +

    + + {% if edit_mode %}{{ _('Edit Event') }}{% else %}{{ _('New Event') }}{% endif %} +

    +
    + +
    + + + +
    + + +
    + + +
    + + +
    + + +
    +
    + + +
    + +
    + + +
    +
    + +
    +
    + + +
    + +
    + + +
    +
    + + +
    + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + +
    + + {{ _('Choose a color for this event') }} +
    +
    + + +
    + +

    {{ _('Private events are only visible to you') }}

    +
    + + +
    +

    {{ _('Recurring Event') }}

    + +
    + +
    + +
    +
    + + +

    {{ _('Use RRULE format (e.g., FREQ=WEEKLY;BYDAY=MO,WE,FR)') }}

    +
    + +
    + + +
    +
    +
    + + +
    + + + {{ _('Cancel') }} + +
    +
    +
    +
    + + +{% endblock %} + diff --git a/app/templates/calendar/view.html b/app/templates/calendar/view.html new file mode 100644 index 0000000..bfd3709 --- /dev/null +++ b/app/templates/calendar/view.html @@ -0,0 +1,139 @@ +{% extends "base.html" %} +{% block title %}Calendar - {{ app_name }}{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
    + +
    + + + +
    + + +
    + +

    +
    + + +
    + + +
    + + + +
    +
    + + +
    +
    + +
    + +

    {{ _('Loading calendar...') }}

    +
    +
    +
    + + + +
    + + + +{% endblock %} + +{% block scripts_extra %} + +{% endblock %} + diff --git a/docs/CALENDAR_AGENDA_FEATURE.md b/docs/CALENDAR_AGENDA_FEATURE.md new file mode 100644 index 0000000..2036fc6 --- /dev/null +++ b/docs/CALENDAR_AGENDA_FEATURE.md @@ -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) + diff --git a/migrations/versions/034_add_calendar_events_table.py b/migrations/versions/034_add_calendar_events_table.py new file mode 100644 index 0000000..b004c8e --- /dev/null +++ b/migrations/versions/034_add_calendar_events_table.py @@ -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') + diff --git a/tests/test_calendar_event_model.py b/tests/test_calendar_event_model.py new file mode 100644 index 0000000..2145c4f --- /dev/null +++ b/tests/test_calendar_event_model.py @@ -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 + diff --git a/tests/test_calendar_routes.py b/tests/test_calendar_routes.py new file mode 100644 index 0000000..4ea113a --- /dev/null +++ b/tests/test_calendar_routes.py @@ -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 + From 7c518171f1b19788523d1684a49b2bbaa370a8c7 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Mon, 27 Oct 2025 12:51:05 +0100 Subject: [PATCH 2/2] fix: use project's custom confirmation dialog for event deletion Replaced browser's native confirm() dialog with the project's custom confirm_dialog macro for consistency with the rest of the application. Changes: - Imported confirm_dialog macro from components/ui.html in event_detail.html - Updated delete button to trigger custom modal instead of native confirm - Modified delete_event route to accept both POST and DELETE methods - Added flash messages and redirect logic for POST-based deletion - Replaced JavaScript fetch-based deletion with form submission pattern - Used hidden form with CSRF token for secure deletion (consistent with tasks and time entries deletion pattern) The custom dialog provides: - Consistent UI/UX matching the project's dark theme - Better accessibility - Proper styling with danger-colored confirmation button - Standard project pattern for destructive actions Fixes: Event deletion showing browser's native unstyled confirmation dialog Related: Calendar feature implementation --- app/routes/calendar.py | 15 ++++++++++++++- app/static/calendar.js | 2 ++ app/templates/calendar/event_detail.html | 22 +++++++++++++++++----- app/templates/calendar/view.html | 2 +- 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/app/routes/calendar.py b/app/routes/calendar.py index ed10380..c4afb4d 100644 --- a/app/routes/calendar.py +++ b/app/routes/calendar.py @@ -240,7 +240,7 @@ def update_event(event_id): return jsonify({'error': f'Error updating event: {str(e)}'}), 500 -@calendar_bp.route('/api/calendar/events/', methods=['DELETE']) +@calendar_bp.route('/api/calendar/events/', methods=['DELETE', 'POST']) @login_required def delete_event(event_id): """Delete a calendar event""" @@ -248,13 +248,23 @@ def delete_event(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') @@ -262,6 +272,9 @@ def delete_event(event_id): 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 diff --git a/app/static/calendar.js b/app/static/calendar.js index 0496969..80b2d5e 100644 --- a/app/static/calendar.js +++ b/app/static/calendar.js @@ -491,6 +491,7 @@ class Calendar { } async showEventDetails(id, type) { + // Navigate to the appropriate detail page if (type === 'event') { window.location.href = `/calendar/event/${id}`; } else if (type === 'task') { @@ -522,6 +523,7 @@ document.addEventListener('DOMContentLoaded', () => { currentDate: window.calendarData.currentDate, apiUrl: window.calendarData.apiUrl }); + } }); diff --git a/app/templates/calendar/event_detail.html b/app/templates/calendar/event_detail.html index 31cc453..0d82936 100644 --- a/app/templates/calendar/event_detail.html +++ b/app/templates/calendar/event_detail.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% from "components/ui.html" import confirm_dialog %} {% block title %}{{ event.title }} - {{ app_name }}{% endblock %} {% block content %} @@ -20,12 +21,13 @@ {{ _('Edit') }} -
    + + - +
    @@ -182,5 +184,15 @@ + + +{{ 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 %} diff --git a/app/templates/calendar/view.html b/app/templates/calendar/view.html index bfd3709..e04fa5a 100644 --- a/app/templates/calendar/view.html +++ b/app/templates/calendar/view.html @@ -134,6 +134,6 @@ {% endblock %} {% block scripts_extra %} - + {% endblock %}