feat(tasks): add activity log, Markdown editor, and dark-mode polish

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
This commit is contained in:
Dries Peeters
2025-09-08 08:06:48 +02:00
parent 6a0f3efe77
commit c297f1503b
11 changed files with 594 additions and 71 deletions

View File

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

View File

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

View File

@@ -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'<TaskActivity task={self.task_id} event={self.event}>'

View File

@@ -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/<int:task_id>/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))

View File

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

View File

@@ -46,10 +46,11 @@
<!-- Description -->
<div class="mb-4">
<label for="description" class="form-label fw-semibold">
<label for="description" class="form-label fw-semibold d-flex align-items-center justify-content-between">
<i class="fas fa-align-left me-2 text-primary"></i>Description
<small class="text-muted">Supports Markdown</small>
</label>
<textarea class="form-control" id="description" name="description" rows="4"
<textarea class="form-control" id="description" name="description" rows="6"
placeholder="Provide detailed information about the task, requirements, and any specific instructions...">{{ request.form.get('description', '') }}</textarea>
<small class="form-text text-muted">Optional: Add context, requirements, or specific instructions for the task</small>
</div>
@@ -419,12 +420,30 @@
}
</style>
<!-- EasyMDE Markdown Editor -->
<link rel="stylesheet" href="https://unpkg.com/easymde/dist/easymde.min.css">
<script src="https://unpkg.com/easymde/dist/easymde.min.js"></script>
<script>
// Form validation and enhancement
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('createTaskForm');
const nameInput = document.getElementById('name');
const descriptionInput = document.getElementById('description');
// Initialize EasyMDE for Markdown input
if (descriptionInput && window.EasyMDE) {
const easyMDE = new EasyMDE({
element: descriptionInput,
spellChecker: false,
status: false,
toolbar: [
'bold','italic','heading','|','unordered-list','ordered-list','|','link','quote','code','table','horizontal-rule','|','preview','guide'
],
renderingConfig: { singleLineBreaks: false },
minHeight: '260px'
});
}
// Auto-resize description textarea
if (descriptionInput) {

View File

@@ -46,11 +46,14 @@
<!-- Description -->
<div class="mb-4">
<label for="description" class="form-label fw-semibold">
<i class="fas fa-align-left me-2 text-primary"></i>Description
<label for="description" class="form-label fw-semibold d-flex align-items-center justify-content-between">
<span><i class="fas fa-align-left me-2 text-primary"></i>Description</span>
<small class="text-muted">Markdown supported</small>
</label>
<textarea class="form-control" id="description" name="description" rows="4"
placeholder="Provide detailed information about the task, requirements, and any specific instructions...">{{ task.description or '' }}</textarea>
<div class="markdown-editor-wrapper">
<textarea class="form-control" id="description" name="description" rows="8"
placeholder="Provide detailed information about the task, requirements, and any specific instructions...">{{ task.description or '' }}</textarea>
</div>
<small class="form-text text-muted">Optional: Add context, requirements, or specific instructions for the task</small>
</div>
@@ -400,6 +403,16 @@
transform: translateX(4px);
}
/* Dark mode for task info blocks to match view page */
[data-theme="dark"] .task-info-item,
[data-theme="dark"] .tip-item {
background-color: #0f172a;
}
[data-theme="dark"] .task-info-item:hover,
[data-theme="dark"] .tip-item:hover {
background-color: #0b1220;
}
/* Form Styling */
.form-control:focus,
.form-select:focus {
@@ -415,6 +428,12 @@
min-height: 56px;
}
/* Markdown editor wrapper to align with cards */
.markdown-editor-wrapper {
border-radius: 8px;
overflow: hidden;
}
/* Mobile Optimizations */
@media (max-width: 768px) {
.card-header {
@@ -443,6 +462,10 @@
}
</style>
<!-- EasyMDE Markdown Editor -->
<link rel="stylesheet" href="https://unpkg.com/easymde/dist/easymde.min.css">
<script src="https://unpkg.com/easymde/dist/easymde.min.js"></script>
<script>
// Form validation and enhancement
document.addEventListener('DOMContentLoaded', function() {
@@ -450,12 +473,25 @@ document.addEventListener('DOMContentLoaded', function() {
const nameInput = document.getElementById('name');
const descriptionInput = document.getElementById('description');
// Auto-resize description textarea
if (descriptionInput) {
descriptionInput.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = (this.scrollHeight) + 'px';
// Initialize EasyMDE for Markdown input
if (descriptionInput && window.EasyMDE) {
const easyMDE = new EasyMDE({
element: descriptionInput,
spellChecker: false,
status: false,
toolbar: [
'bold','italic','heading','|','unordered-list','ordered-list','|','link','quote','code','table','horizontal-rule','|','preview','guide'
],
renderingConfig: { singleLineBreaks: false },
minHeight: '260px'
});
// Ensure side-by-side is not persisted from previous sessions
try {
const container = descriptionInput.closest('.EasyMDEContainer');
if (container && container.classList.contains('side-by-side')) {
easyMDE.toggleSideBySide();
}
} catch (e) {}
}
// Form submission enhancement

View File

@@ -25,9 +25,6 @@
</div>
<div class="flex-grow-1">
<h1 class="h2 mb-2">{{ task.name }}</h1>
{% if task.description %}
<p class="text-muted mb-0">{{ task.description }}</p>
{% endif %}
</div>
</div>
@@ -76,6 +73,22 @@
<div class="row g-4">
<!-- Main Task Information -->
<div class="col-lg-8">
<!-- Task Description -->
{% if task.description %}
<div class="card mobile-card mb-4">
<div class="card-header d-flex align-items-center justify-content-between">
<h6 class="mb-0">
<i class="fas fa-align-left me-2 text-primary"></i>Description
</h6>
</div>
<div class="card-body">
<div class="task-description-content">
{{ task.description | markdown | safe }}
</div>
</div>
</div>
{% endif %}
<!-- Project Information -->
<div class="card mobile-card mb-4">
<div class="card-header">
@@ -178,8 +191,8 @@
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for entry in time_entries[:5] %}
<tbody id="timeEntriesTableBody">
{% for entry in time_entries %}
<tr>
<td>{{ entry.start_time.strftime('%b %d, %Y') }}</td>
<td>
@@ -191,14 +204,14 @@
</td>
<td>{{ entry.notes[:50] if entry.notes else '-' }}</td>
<td>{{ entry.user.display_name if entry.user else '-' }}</td>
<td class="text-end">
<td class="text-end pe-4 actions-cell">
<div class="btn-group" role="group">
<a href="{{ url_for('timer.edit_timer', timer_id=entry.id) }}"
class="btn btn-sm btn-outline-primary" title="Edit">
class="btn btn-sm btn-action btn-action--edit touch-target" title="Edit entry">
<i class="fas fa-edit"></i>
</a>
{% if current_user.is_admin or entry.user_id == current_user.id %}
<button type="button" class="btn btn-sm btn-outline-danger" title="Delete"
<button type="button" class="btn btn-sm btn-action btn-action--danger touch-target" title="Delete entry"
onclick="showDeleteEntryModal('{{ entry.id }}', '{{ task.project.name }}', '{{ entry.duration_formatted }}')">
<i class="fas fa-trash"></i>
</button>
@@ -210,9 +223,17 @@
</tbody>
</table>
</div>
{% if task.time_entries.count() > 5 %}
<div class="text-center mt-3">
<small class="text-muted">Showing 5 of {{ task.time_entries.count() }} entries</small>
{% if task.time_entries.count() > 10 %}
<div class="d-flex justify-content-between align-items-center mt-3">
<small class="text-muted" id="entriesInfo">Showing 10 of {{ task.time_entries.count() }} entries</small>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-secondary" id="showMoreBtn" onclick="showMoreEntries()">
<i class="fas fa-chevron-down me-2"></i>Show more
</button>
<button class="btn btn-sm btn-outline-secondary d-none" id="showLessBtn" onclick="showLessEntries()">
<i class="fas fa-chevron-up me-2"></i>Show less
</button>
</div>
</div>
{% endif %}
</div>
@@ -314,34 +335,38 @@
</h6>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<div class="quick-actions d-flex flex-wrap gap-2">
{% if task.status == 'todo' %}
<button class="btn btn-warning btn-sm" onclick="updateTaskStatus('in_progress')">
<i class="fas fa-play me-2"></i>Start Task
<button class="btn btn-sm btn-action btn-action--success" onclick="openStatusModal('in_progress')" title="Start Task" data-bs-toggle="tooltip">
<i class="fas fa-play"></i>
</button>
{% elif task.status == 'in_progress' %}
<button class="btn btn-info btn-sm" onclick="updateTaskStatus('review')">
<i class="fas fa-eye me-2"></i>Mark for Review
</button>
<button class="btn btn-secondary btn-sm" onclick="updateTaskStatus('todo')">
<i class="fas fa-pause me-2"></i>Pause Task
</button>
<div class="btn-group" role="group">
<button class="btn btn-sm btn-action btn-action--view" onclick="openStatusModal('review')" title="Mark for Review" data-bs-toggle="tooltip">
<i class="fas fa-eye"></i>
</button>
<button class="btn btn-sm btn-action btn-action--warning" onclick="openStatusModal('todo')" title="Pause Task" data-bs-toggle="tooltip">
<i class="fas fa-pause"></i>
</button>
</div>
{% elif task.status == 'review' %}
<button class="btn btn-success btn-sm" onclick="updateTaskStatus('done')">
<i class="fas fa-check me-2"></i>Complete Task
</button>
<button class="btn btn-warning btn-sm" onclick="updateTaskStatus('in_progress')">
<i class="fas fa-undo me-2"></i>Back to Progress
</button>
<div class="btn-group" role="group">
<button class="btn btn-sm btn-action btn-action--success" onclick="openStatusModal('done')" title="Complete Task" data-bs-toggle="tooltip">
<i class="fas fa-check"></i>
</button>
<button class="btn btn-sm btn-action btn-action--edit" onclick="openStatusModal('in_progress')" title="Back to Progress" data-bs-toggle="tooltip">
<i class="fas fa-undo"></i>
</button>
</div>
{% elif task.status == 'done' %}
<button class="btn btn-warning btn-sm" onclick="updateTaskStatus('todo')">
<i class="fas fa-undo me-2"></i>Reopen Task
<button class="btn btn-sm btn-action btn-action--warning" onclick="openStatusModal('todo')" title="Reopen Task" data-bs-toggle="tooltip">
<i class="fas fa-undo"></i>
</button>
{% endif %}
{% if task.status != 'done' and task.status != 'cancelled' %}
<button class="btn btn-danger btn-sm" onclick="updateTaskStatus('cancelled')">
<i class="fas fa-times me-2"></i>Cancel Task
<button class="btn btn-sm btn-action btn-action--danger" onclick="openStatusModal('cancelled')" title="Cancel Task" data-bs-toggle="tooltip">
<i class="fas fa-times"></i>
</button>
{% endif %}
</div>
@@ -395,6 +420,41 @@
</div>
</div>
</div>
<!-- Activity Log -->
{% if task.activities.count() > 0 %}
<div class="card mobile-card mt-4">
<div class="card-header">
<h6 class="mb-0">
<i class="fas fa-clipboard-list me-2 text-secondary"></i>Activity Log
</h6>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>When</th>
<th>Event</th>
<th>User</th>
<th>Details</th>
</tr>
</thead>
<tbody>
{% for act in activities %}
<tr>
<td>{{ act.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td class="text-capitalize">{{ act.event.replace('_', ' ') }}</td>
<td>{{ act.user.display_name if act.user else '-' }}</td>
<td class="text-muted">{{ act.details or '' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
@@ -555,29 +615,128 @@
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
/* Dark mode for task details */
[data-theme="dark"] .task-detail-item {
background-color: #0f172a;
}
[data-theme="dark"] .task-detail-item:hover {
background-color: #0b1220;
}
[data-theme="dark"] .timeline::before { background-color: #1f2937; }
[data-theme="dark"] .timeline-marker { box-shadow: 0 0 0 2px #1f2937; }
[data-theme="dark"] .card.mobile-card .card-header h6 { color: #e5e7eb; }
.btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
</style>
<style>
/* Quick actions: prevent full-width buttons, improve spacing */
.quick-actions .btn { width: auto; }
@media (max-width: 768px) {
.quick-actions { gap: 0.5rem; }
}
</style>
<script>
function updateTaskStatus(status) {
if (confirm('Are you sure you want to update the task status?')) {
// Simple form submission approach instead of fetch with CSRF token
const form = document.createElement('form');
form.method = 'POST';
form.action = '{{ url_for("tasks.update_task_status", task_id=task.id) }}';
const statusInput = document.createElement('input');
statusInput.type = 'hidden';
statusInput.name = 'status';
statusInput.value = status;
form.appendChild(statusInput);
document.body.appendChild(form);
form.submit();
// Build and submit form without default confirm
const form = document.createElement('form');
form.method = 'POST';
form.action = '{{ url_for("tasks.update_task_status", task_id=task.id) }}';
const statusInput = document.createElement('input');
statusInput.type = 'hidden';
statusInput.name = 'status';
statusInput.value = status;
form.appendChild(statusInput);
document.body.appendChild(form);
form.submit();
}
function toggleEntries() { /* deprecated - replaced by showMoreEntries/showLessEntries */ }
document.addEventListener('DOMContentLoaded', function() {
const rows = document.querySelectorAll('#timeEntriesTableBody tr');
const total = rows.length;
const initial = Math.min(10, total);
rows.forEach((row, idx) => row.style.display = (idx < initial ? '' : 'none'));
const info = document.getElementById('entriesInfo');
if (info && total > 10) info.textContent = `Showing ${initial} of ${total} entries`;
// Initialize tooltips
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
tooltipTriggerList.forEach(function (tooltipTriggerEl) {
new bootstrap.Tooltip(tooltipTriggerEl)
})
});
// Quick Action Modal
let pendingStatus = null;
function openStatusModal(status) {
pendingStatus = status;
const titleMap = {
'in_progress': 'Start Task',
'review': 'Mark for Review',
'todo': 'Pause / Reopen Task',
'done': 'Complete Task',
'cancelled': 'Cancel Task'
};
const bodyMap = {
'in_progress': 'Are you sure you want to start this task now?',
'review': 'Move this task to Review?',
'todo': 'Move this task to To Do?',
'done': 'Mark this task as Completed?',
'cancelled': 'Cancel this task? You can reopen it later.'
};
const modalEl = document.getElementById('statusConfirmModal');
modalEl.querySelector('.modal-title').textContent = titleMap[status] || 'Confirm Action';
modalEl.querySelector('.modal-body-text').textContent = bodyMap[status] || 'Are you sure?';
const modal = new bootstrap.Modal(modalEl);
modal.show();
}
function confirmStatusChange() {
if (!pendingStatus) return;
const btn = document.getElementById('statusConfirmBtn');
if (btn) {
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Working...';
}
updateTaskStatus(pendingStatus);
}
// Progressive show more/less for time entries
function showMoreEntries() {
const rows = Array.from(document.querySelectorAll('#timeEntriesTableBody tr'));
const info = document.getElementById('entriesInfo');
const showMoreBtn = document.getElementById('showMoreBtn');
const showLessBtn = document.getElementById('showLessBtn');
const currentlyVisible = rows.filter(r => r.style.display !== 'none').length;
const toShow = Math.min(currentlyVisible + 10, rows.length);
rows.forEach((row, idx) => row.style.display = (idx < toShow ? '' : 'none'));
if (info) info.textContent = (toShow < rows.length) ? `Showing ${toShow} of ${rows.length} entries` : `Showing all ${rows.length} entries`;
if (toShow >= rows.length) {
showMoreBtn.classList.add('d-none');
showLessBtn.classList.remove('d-none');
} else {
showLessBtn.classList.remove('d-none');
}
}
function showLessEntries() {
const rows = Array.from(document.querySelectorAll('#timeEntriesTableBody tr'));
const info = document.getElementById('entriesInfo');
const showMoreBtn = document.getElementById('showMoreBtn');
const showLessBtn = document.getElementById('showLessBtn');
const initial = Math.min(10, rows.length);
rows.forEach((row, idx) => row.style.display = (idx < initial ? '' : 'none'));
if (info) info.textContent = `Showing ${initial} of ${rows.length} entries`;
showMoreBtn.classList.remove('d-none');
showLessBtn.classList.add('d-none');
}
// Function to show delete time entry modal
@@ -607,6 +766,29 @@ document.addEventListener('DOMContentLoaded', function() {
});
</script>
<!-- Status Confirm Modal -->
<div class="modal fade" id="statusConfirmModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Confirm Action</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="modal-body-text">Are you sure?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="fas fa-times me-2"></i>Cancel
</button>
<button type="button" id="statusConfirmBtn" class="btn btn-primary" onclick="confirmStatusChange()">
<i class="fas fa-check me-2"></i>Confirm
</button>
</div>
</div>
</div>
</div>
<!-- Delete Time Entry Modal -->
<div class="modal fade" id="deleteEntryModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">

View File

@@ -1,5 +1,11 @@
from flask import Blueprint
from app.utils.timezone import utc_to_local, format_local_datetime
try:
import markdown as _md
import bleach
except Exception:
_md = None
bleach = None
def register_template_filters(app):
"""Register custom template filters for the application"""
@@ -38,3 +44,27 @@ def register_template_filters(app):
if text is None:
return ""
return text.replace('\n', '<br>')
@app.template_filter('markdown')
def markdown_filter(text):
"""Render markdown to safe HTML using bleach sanitation."""
if not text:
return ""
if _md is None:
# Fallback: escape and basic nl2br
try:
from markupsafe import escape
except Exception:
return text
return escape(text).replace('\n', '<br>')
html = _md.markdown(text, extensions=['extra', 'sane_lists', 'smarty'])
if bleach is None:
return html
allowed_tags = bleach.sanitizer.ALLOWED_TAGS.union({'p','pre','code','img','h1','h2','h3','h4','h5','h6','table','thead','tbody','tr','th','td','hr','br','ul','ol','li','strong','em','blockquote','a'})
allowed_attrs = {
**bleach.sanitizer.ALLOWED_ATTRIBUTES,
'a': ['href', 'title', 'rel', 'target'],
'img': ['src', 'alt', 'title'],
}
return bleach.clean(html, tags=allowed_tags, attributes=allowed_attrs, strip=True)

View File

@@ -0,0 +1,44 @@
"""add task_activities table
Revision ID: 004
Revises: 003
Create Date: 2025-09-07 10:35:00
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '004'
down_revision = '003'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
'task_activities',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('task_id', sa.Integer(), sa.ForeignKey('tasks.id', ondelete='CASCADE'), nullable=False, index=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True),
sa.Column('event', sa.String(length=50), nullable=False, index=True),
sa.Column('details', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
)
# Explicit indexes (in addition to inline index=True for portability)
op.create_index('idx_task_activities_task_id', 'task_activities', ['task_id'])
op.create_index('idx_task_activities_user_id', 'task_activities', ['user_id'])
op.create_index('idx_task_activities_event', 'task_activities', ['event'])
op.create_index('idx_task_activities_created_at', 'task_activities', ['created_at'])
def downgrade() -> None:
op.drop_index('idx_task_activities_created_at', table_name='task_activities')
op.drop_index('idx_task_activities_event', table_name='task_activities')
op.drop_index('idx_task_activities_user_id', table_name='task_activities')
op.drop_index('idx_task_activities_task_id', table_name='task_activities')
op.drop_table('task_activities')

View File

@@ -37,3 +37,5 @@ flake8==6.1.0
# Security
cryptography==45.0.6
markdown==3.6
bleach==6.1.0