From c297f1503b4c6e79eb4e82fc753fee567dc773d0 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Mon, 8 Sep 2025 08:06:48 +0200 Subject: [PATCH] feat(tasks): add activity log, Markdown editor, and dark-mode polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend - Add TaskActivity model to record start/pause/review/complete/cancel/reopen - Preserve started_at when reopening tasks; keep timestamps in app-local time - Expose recent activities on task detail view Migrations - Add 004_add_task_activities_table (FKs + indexes) Task detail (UI) - Move Description into its own card and render as Markdown (markdown + bleach) - Restyle Quick Actions to use dashboard btn-action styles - Add custom confirmation modal + tooltips - Recent Time Entries: use dashboard action buttons, add progressive “Show more/less” (10 per click) Tasks list - Fix dark mode for filter UI (inputs, selects, input-group-text, checkboxes) Create/Edit task - Integrate EasyMDE Markdown editor with toolbar - Strong dark-theme overrides (toolbar, editor, preview, status bar, tokens) - Prevent unintended side-by-side persistence - Align “Current Task Info” dark-mode styles with task detail CSS - Add dark-mode tints for action buttons, tooltip light theme in dark mode - Editor layout polish (padding, focus ring, gutters, selection) - Quick actions layout: compact horizontal group Deps - Add: markdown, bleach Run - flask db upgrade # applies 004_add_task_activities_table --- app/__init__.py | 4 +- app/models/__init__.py | 3 +- app/models/task_activity.py | 28 ++ app/routes/tasks.py | 50 +++- app/static/base.css | 157 +++++++++- app/templates/tasks/create.html | 23 +- app/templates/tasks/edit.html | 54 +++- app/templates/tasks/view.html | 270 +++++++++++++++--- app/utils/template_filters.py | 30 ++ .../versions/004_add_task_activities_table.py | 44 +++ requirements.txt | 2 + 11 files changed, 594 insertions(+), 71 deletions(-) create mode 100644 app/models/task_activity.py create mode 100644 migrations/versions/004_add_task_activities_table.py diff --git a/app/__init__.py b/app/__init__.py index 61d498f..09026e0 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -200,7 +200,7 @@ def create_app(config=None): def initialize_database(): try: # Import models to ensure they are registered - from app.models import User, Project, TimeEntry, Task, Settings + from app.models import User, Project, TimeEntry, Task, Settings, TaskActivity # Create database tables db.create_all() @@ -322,7 +322,7 @@ def init_database(app): with app.app_context(): try: # Import models to ensure they are registered - from app.models import User, Project, TimeEntry, Task, Settings + from app.models import User, Project, TimeEntry, Task, Settings, TaskActivity # Create database tables db.create_all() diff --git a/app/models/__init__.py b/app/models/__init__.py index daff713..69bc7ad 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -5,5 +5,6 @@ from .task import Task from .settings import Settings from .invoice import Invoice, InvoiceItem from .client import Client +from .task_activity import TaskActivity -__all__ = ['User', 'Project', 'TimeEntry', 'Task', 'Settings', 'Invoice', 'InvoiceItem', 'Client'] +__all__ = ['User', 'Project', 'TimeEntry', 'Task', 'Settings', 'Invoice', 'InvoiceItem', 'Client', 'TaskActivity'] diff --git a/app/models/task_activity.py b/app/models/task_activity.py new file mode 100644 index 0000000..b895e6e --- /dev/null +++ b/app/models/task_activity.py @@ -0,0 +1,28 @@ +from app import db +from app.utils.timezone import now_in_app_timezone + + +class TaskActivity(db.Model): + """Lightweight audit log for significant task events.""" + __tablename__ = 'task_activities' + + id = db.Column(db.Integer, primary_key=True) + task_id = db.Column(db.Integer, db.ForeignKey('tasks.id'), nullable=False, index=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True, index=True) + event = db.Column(db.String(50), nullable=False, index=True) + details = db.Column(db.Text, nullable=True) + created_at = db.Column(db.DateTime, default=now_in_app_timezone, nullable=False, index=True) + + task = db.relationship('Task', backref=db.backref('activities', lazy='dynamic', cascade='all, delete-orphan')) + user = db.relationship('User') + + def __init__(self, task_id, event, user_id=None, details=None): + self.task_id = task_id + self.user_id = user_id + self.event = event + self.details = details + + def __repr__(self): + return f'' + + diff --git a/app/routes/tasks.py b/app/routes/tasks.py index c542ab3..d828913 100644 --- a/app/routes/tasks.py +++ b/app/routes/tasks.py @@ -1,7 +1,7 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify from flask_login import login_required, current_user from app import db -from app.models import Task, Project, User, TimeEntry +from app.models import Task, Project, User, TimeEntry, TaskActivity from datetime import datetime, date from decimal import Decimal from app.utils.db import safe_commit @@ -166,8 +166,10 @@ def view_task(task_id): # Get time entries for this task time_entries = task.time_entries.order_by(TimeEntry.start_time.desc()).all() + # Recent activity entries + activities = task.activities.order_by(TaskActivity.created_at.desc()).limit(20).all() - return render_template('tasks/view.html', task=task, time_entries=time_entries) + return render_template('tasks/view.html', task=task, time_entries=time_entries, activities=activities) @tasks_bp.route('/tasks//edit', methods=['GET', 'POST']) @login_required @@ -221,19 +223,40 @@ def edit_task(task_id): valid_statuses = ['todo', 'in_progress', 'review', 'done', 'cancelled'] if selected_status and selected_status in valid_statuses and selected_status != task.status: try: + previous_status = task.status if selected_status == 'in_progress': - task.start_task() + # If reopening from done, preserve started_at + if task.status == 'done': + task.completed_at = None + task.status = 'in_progress' + if not task.started_at: + task.started_at = now_in_app_timezone() + task.updated_at = now_in_app_timezone() + db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event='reopen', details='Task reopened to In Progress')) + if not safe_commit('edit_task_reopen_in_progress', {'task_id': task.id}): + flash('Could not update status due to a database error. Please check server logs.', 'error') + return render_template('tasks/edit.html', task=task) + else: + task.start_task() + db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event='start', details=f"Task moved from {previous_status} to In Progress")) + safe_commit('log_task_start_from_edit', {'task_id': task.id}) elif selected_status == 'done': task.complete_task() + db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event='complete', details='Task completed')) + safe_commit('log_task_complete_from_edit', {'task_id': task.id}) elif selected_status == 'cancelled': task.cancel_task() + db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event='cancel', details='Task cancelled')) + safe_commit('log_task_cancel_from_edit', {'task_id': task.id}) else: # Reopen or move to non-special states # Clear completed_at if reopening from done - if task.status == 'done' and selected_status in ['todo', 'in_progress', 'review']: + if task.status == 'done' and selected_status in ['todo', 'review']: task.completed_at = None task.status = selected_status task.updated_at = now_in_app_timezone() + event_name = 'reopen' if previous_status == 'done' and selected_status in ['todo', 'review'] else ('pause' if selected_status == 'todo' else ('review' if selected_status == 'review' else 'status_change')) + db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event=event_name, details=f"Task moved from {previous_status} to {selected_status}")) if not safe_commit('edit_task_status_change', {'task_id': task.id, 'status': selected_status}): flash('Could not update status due to a database error. Please check server logs.', 'error') return render_template('tasks/edit.html', task=task) @@ -286,21 +309,40 @@ def update_task_status(task_id): if not task.started_at: task.started_at = now_in_app_timezone() task.updated_at = now_in_app_timezone() + db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event='reopen', details='Task reopened to In Progress')) if not safe_commit('update_task_status_reopen_in_progress', {'task_id': task.id, 'status': new_status}): flash('Could not update status due to a database error. Please check server logs.', 'error') return redirect(url_for('tasks.view_task', task_id=task.id)) else: + previous_status = task.status task.start_task() + db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event='start', details=f"Task moved from {previous_status} to In Progress")) + safe_commit('log_task_start', {'task_id': task.id}) elif new_status == 'done': task.complete_task() + db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event='complete', details='Task completed')) + safe_commit('log_task_complete', {'task_id': task.id}) elif new_status == 'cancelled': task.cancel_task() + db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event='cancel', details='Task cancelled')) + safe_commit('log_task_cancel', {'task_id': task.id}) else: # For other transitions, handle reopening from done and local timestamps if task.status == 'done' and new_status in ['todo', 'review']: task.completed_at = None + previous_status = task.status task.status = new_status task.updated_at = now_in_app_timezone() + # Log pause or review or generic change + if previous_status == 'done' and new_status in ['todo', 'review']: + event_name = 'reopen' + else: + event_map = { + 'todo': 'pause', + 'review': 'review', + } + event_name = event_map.get(new_status, 'status_change') + db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event=event_name, details=f"Task moved from {previous_status} to {new_status}")) if not safe_commit('update_task_status', {'task_id': task.id, 'status': new_status}): flash('Could not update status due to a database error. Please check server logs.', 'error') return redirect(url_for('tasks.view_task', task_id=task.id)) diff --git a/app/static/base.css b/app/static/base.css index 1f9bfce..84d2452 100644 --- a/app/static/base.css +++ b/app/static/base.css @@ -376,9 +376,120 @@ main { background: #0f172a; color: var(--text-primary); } +[data-theme="dark"] textarea.form-control { + background: #0f172a; + color: var(--text-primary); + border-color: var(--border-color); +} + +/* EasyMDE (Markdown editor) dark theme overrides */ +[data-theme="dark"] .EasyMDEContainer .editor-toolbar { + background: #0b1220 !important; + border-color: var(--border-color) !important; + box-shadow: inset 0 -1px 0 rgba(255,255,255,0.03); +} +[data-theme="dark"] .EasyMDEContainer .editor-toolbar a { + color: var(--text-secondary) !important; +} +[data-theme="dark"] .EasyMDEContainer .editor-toolbar a:hover, +[data-theme="dark"] .EasyMDEContainer .editor-toolbar a.active { + background: #111827 !important; + color: var(--text-primary) !important; +} +[data-theme="dark"] .EasyMDEContainer .CodeMirror, +[data-theme="dark"] .EasyMDEContainer .CodeMirror-scroll, +[data-theme="dark"] .EasyMDEContainer .cm-s-easymde.CodeMirror, +[data-theme="dark"] .EasyMDEContainer .cm-s-easymde .CodeMirror-scroll { + background: #0f172a !important; + color: var(--text-primary) !important; +} +[data-theme="dark"] .EasyMDEContainer .cm-s-easymde.CodeMirror { + border: 1px solid var(--border-color) !important; + border-top: none !important; /* aligns with toolbar border */ +} +[data-theme="dark"] .EasyMDEContainer .cm-s-easymde .CodeMirror-gutters { + background: #0f172a !important; + border-right: 1px solid var(--border-color) !important; +} +[data-theme="dark"] .EasyMDEContainer .CodeMirror-selected { + background: rgba(59,130,246,0.25) !important; +} +[data-theme="dark"] .EasyMDEContainer .cm-s-easymde .cm-url { color: #93c5fd !important; } +[data-theme="dark"] .EasyMDEContainer .cm-s-easymde .cm-code { color: #fca5a5 !important; } +[data-theme="dark"] .EasyMDEContainer .cm-s-easymde .cm-hr { color: #475569 !important; } +[data-theme="dark"] .EasyMDEContainer .CodeMirror pre { color: var(--text-primary) !important; } +[data-theme="dark"] .EasyMDEContainer .CodeMirror-cursor { border-left-color: #e5e7eb !important; } +[data-theme="dark"] .EasyMDEContainer .CodeMirror .CodeMirror-placeholder { color: #64748b !important; } +[data-theme="dark"] .EasyMDEContainer .editor-statusbar { + background: #0b1220 !important; + color: var(--text-secondary) !important; + border-color: var(--border-color) !important; +} +[data-theme="dark"] .EasyMDEContainer .editor-preview, +[data-theme="dark"] .EasyMDEContainer .editor-preview-side { + background: #0f172a !important; + color: var(--text-primary) !important; +} +/* Token colors for dark mode (readability) */ +[data-theme="dark"] .EasyMDEContainer .cm-header { color: #93c5fd !important; } +[data-theme="dark"] .EasyMDEContainer .cm-strong { color: #e5e7eb !important; } +[data-theme="dark"] .EasyMDEContainer .cm-em { color: #fca5a5 !important; } +[data-theme="dark"] .EasyMDEContainer .cm-quote { color: #a7f3d0 !important; } +[data-theme="dark"] .EasyMDEContainer .cm-link { color: #60a5fa !important; text-decoration: underline; } +[data-theme="dark"] .EasyMDEContainer .cm-formatting-header { color: #60a5fa !important; } + +/* EasyMDE global layout enhancements (both themes) */ +.EasyMDEContainer { + border: 1px solid var(--border-color); + border-radius: var(--border-radius-sm); + overflow: hidden; + background: transparent; +} +.EasyMDEContainer .editor-toolbar { + border-bottom: 1px solid var(--border-color); + padding: 0.25rem 0.5rem; +} +.EasyMDEContainer .editor-toolbar a { + border-radius: 6px; + height: 32px; + width: 32px; + display: inline-flex; + align-items: center; + justify-content: center; +} +.EasyMDEContainer .editor-toolbar .separator { + border-left: 1px solid var(--border-color); + margin: 0 0.35rem; +} +.EasyMDEContainer .CodeMirror { + min-height: 260px; + font-size: 0.95rem; + line-height: 1.5; +} +.EasyMDEContainer .editor-preview-side { max-width: 50%; } +.EasyMDEContainer .CodeMirror, .EasyMDEContainer .editor-preview-side { box-sizing: border-box; } +.EasyMDEContainer .CodeMirror-scroll { + padding: 0.75rem 1rem; +} +.EasyMDEContainer .CodeMirror-focused { + box-shadow: 0 0 0 3px rgba(59,130,246,0.15); +} +.EasyMDEContainer .editor-statusbar { + border-top: 1px solid var(--border-color); + padding: 0.375rem 0.75rem; +} +[data-theme="dark"] .input-group-text { + background: #111827; + color: var(--text-secondary); + border-color: var(--border-color); +} [data-theme="dark"] .form-text { color: var(--text-muted); } [data-theme="dark"] .form-control::placeholder { color: #64748b; } [data-theme="dark"] .form-select option { background: #0f172a; color: var(--text-primary); } +[data-theme="dark"] .form-check-input { + background-color: #0f172a; + border-color: var(--border-color); +} .form-control:focus, .form-select:focus { border-color: var(--primary-color); @@ -505,31 +616,36 @@ main { background: #0f172a; border-color: var(--border-color); color: var(--text-secondary); + box-shadow: inset 0 0 0 1px rgba(255,255,255,0.03); } [data-theme="dark"] .btn-action:hover { background: #111827; } [data-theme="dark"] .btn-action--view { - color: #93c5fd; + color: #60a5fa; + background: rgba(59, 130, 246, 0.08); border-color: rgba(59, 130, 246, 0.35); } -[data-theme="dark"] .btn-action--view:hover { background: rgba(59, 130, 246, 0.08); } +[data-theme="dark"] .btn-action--view:hover { background: rgba(59, 130, 246, 0.12); } [data-theme="dark"] .btn-action--edit { color: var(--text-secondary); border-color: var(--border-color); } [data-theme="dark"] .btn-action--edit:hover { background: #111827; } [data-theme="dark"] .btn-action--danger { - color: #fca5a5; - border-color: rgba(220, 38, 38, 0.45); + color: #ef4444; + background: rgba(239, 68, 68, 0.06); + border-color: rgba(220, 38, 38, 0.35); } [data-theme="dark"] .btn-action--danger:hover { background: rgba(220, 38, 38, 0.12); } [data-theme="dark"] .btn-action--success { - color: #86efac; - border-color: rgba(16, 185, 129, 0.45); + color: #22c55e; + background: rgba(16, 185, 129, 0.08); + border-color: rgba(16, 185, 129, 0.35); } -[data-theme="dark"] .btn-action--success:hover { background: rgba(16, 185, 129, 0.12); } +[data-theme="dark"] .btn-action--success:hover { background: rgba(16, 185, 129, 0.14); } [data-theme="dark"] .btn-action--warning { - color: #fcd34d; - border-color: rgba(245, 158, 11, 0.55); + color: #f59e0b; + background: rgba(245, 158, 11, 0.06); + border-color: rgba(245, 158, 11, 0.35); } [data-theme="dark"] .btn-action--warning:hover { background: rgba(245, 158, 11, 0.12); } [data-theme="dark"] .btn-action--more { @@ -1371,3 +1487,26 @@ h6 { font-size: 1rem; } border-radius: 9999px; } +/* Tooltip overrides to keep light tooltips in dark mode */ +[data-theme="dark"] .tooltip .tooltip-inner { + background-color: #f9fafb; /* light */ + color: #111827; /* dark text */ + border: 1px solid rgba(0,0,0,0.1); +} +[data-theme="dark"] .tooltip.bs-tooltip-top .tooltip-arrow::before, +[data-theme="dark"] .tooltip.bs-tooltip-auto[data-popper-placement^="top"] .tooltip-arrow::before { + border-top-color: #f9fafb; +} +[data-theme="dark"] .tooltip.bs-tooltip-bottom .tooltip-arrow::before, +[data-theme="dark"] .tooltip.bs-tooltip-auto[data-popper-placement^="bottom"] .tooltip-arrow::before { + border-bottom-color: #f9fafb; +} +[data-theme="dark"] .tooltip.bs-tooltip-start .tooltip-arrow::before, +[data-theme="dark"] .tooltip.bs-tooltip-auto[data-popper-placement^="left"] .tooltip-arrow::before { + border-left-color: #f9fafb; +} +[data-theme="dark"] .tooltip.bs-tooltip-end .tooltip-arrow::before, +[data-theme="dark"] .tooltip.bs-tooltip-auto[data-popper-placement^="right"] .tooltip-arrow::before { + border-right-color: #f9fafb; +} + diff --git a/app/templates/tasks/create.html b/app/templates/tasks/create.html index 0117331..0e5392a 100644 --- a/app/templates/tasks/create.html +++ b/app/templates/tasks/create.html @@ -46,10 +46,11 @@
-
@@ -419,12 +420,30 @@ } + + + + + + + +