feat(analytics/dashboard): add 'today-by-task' API and dashboard card

- Add GET /api/analytics/today-by-task to return today’s totals grouped by task
  - Defaults to current user; admins can pass user_id
  - Optional date=YYYY-MM-DD
  - Groups by project/task; includes project-level entries without a task as “No task”
  - Sums durations across multiple entries for the same task (e.g., 08–09 + 11–12 = 2.00h)
  - Returns project_name, task_name, total_hours, total_seconds
- Dashboard: add “Today by Task” card that fetches and renders aggregated hours
  - Shows empty-state message when no data
  - Adds small i18n keys for labels

No DB migrations. Backwards compatible.
This commit is contained in:
Dries Peeters
2025-10-05 11:23:23 +02:00
parent eba5afbede
commit 4a33535424
2 changed files with 132 additions and 2 deletions

View File

@@ -1,7 +1,7 @@
from flask import Blueprint, render_template, request, jsonify
from flask_login import login_required, current_user
from app import db
from app.models import User, Project, TimeEntry, Settings
from app.models import User, Project, TimeEntry, Settings, Task
from datetime import datetime, timedelta
from sqlalchemy import func, extract
import calendar
@@ -328,3 +328,74 @@ def project_efficiency():
}
]
})
@analytics_bp.route('/api/analytics/today-by-task')
@login_required
def today_by_task():
"""Get today's total hours grouped by task (includes project-level entries without task).
Optional query params:
- date: YYYY-MM-DD (defaults to today)
- user_id: admin-only override to view a specific user's data
"""
# Parse target date
date_str = request.args.get('date')
if date_str:
try:
target_date = datetime.strptime(date_str, '%Y-%m-%d').date()
except ValueError:
return jsonify({'error': 'Invalid date format, expected YYYY-MM-DD'}), 400
else:
target_date = datetime.now().date()
# Base query
query = db.session.query(
TimeEntry.task_id,
Task.name.label('task_name'),
TimeEntry.project_id,
Project.name.label('project_name'),
func.sum(TimeEntry.duration_seconds).label('total_seconds')
).join(
Project, Project.id == TimeEntry.project_id
).outerjoin(
Task, Task.id == TimeEntry.task_id
).filter(
TimeEntry.end_time.isnot(None),
func.date(TimeEntry.start_time) == target_date
)
# Scope to current user unless admin (with optional override)
if not current_user.is_admin:
query = query.filter(TimeEntry.user_id == current_user.id)
else:
user_id = request.args.get('user_id', type=int)
if user_id:
query = query.filter(TimeEntry.user_id == user_id)
results = query.group_by(
TimeEntry.task_id,
Task.name,
TimeEntry.project_id,
Project.name
).order_by(func.sum(TimeEntry.duration_seconds).desc()).all()
rows = []
for task_id, task_name, project_id, project_name, total_seconds in results:
total_seconds = int(total_seconds or 0)
total_hours = round(total_seconds / 3600, 2)
label = f"{project_name}{task_name}" if task_name else f"{project_name} • No task"
rows.append({
'task_id': task_id,
'task_name': task_name,
'project_id': project_id,
'project_name': project_name,
'total_seconds': total_seconds,
'total_hours': total_hours,
'label': label
})
return jsonify({
'date': target_date.strftime('%Y-%m-%d'),
'rows': rows
})

View File

@@ -181,6 +181,24 @@
</div>
</div>
<!-- Today by Task -->
<div class="row section-spacing">
<div class="col-12">
<div class="card mobile-card hover-lift">
<div class="card-header d-flex align-items-center justify-content-between">
<h5 class="mb-0 d-flex align-items-center">
<i class="fas fa-list-check me-3 text-secondary"></i>{{ _('Today by Task') }}
</h5>
<small class="text-muted" id="today-by-task-date"></small>
</div>
<div class="card-body" id="today-by-task">
<div class="text-muted">{{ _('Loading...') }}</div>
</div>
</div>
</div>
</div>
<!-- Enhanced Recent Entries -->
<div class="row">
<div class="col-12">
@@ -366,7 +384,10 @@
'choose_task': _('Choose a task...'),
'please_select_project': _('Please select a project'),
'starting': _('Starting...'),
'deleting': _('Deleting...')
'deleting': _('Deleting...'),
'today_by_task': _('Today by Task'),
'no_data': _('No time tracked yet today'),
'hours_suffix': _('h')
}|tojson }}</script>
<script>
// Parse page i18n
@@ -448,6 +469,44 @@
});
}
// Fetch and render Today by Task
(function(){
const container = document.getElementById('today-by-task');
const dateEl = document.getElementById('today-by-task-date');
if (!container) return;
function renderRows(rows){
if (!rows || rows.length === 0) {
container.innerHTML = `<div class="text-muted">${i18nDash.no_data || 'No time tracked yet today'}</div>`;
return;
}
const list = document.createElement('div');
list.className = 'list-group list-group-flush';
rows.forEach(r => {
const item = document.createElement('div');
item.className = 'list-group-item d-flex justify-content-between align-items-center';
const left = document.createElement('div');
left.className = 'd-flex align-items-center';
left.innerHTML = `<div class="bg-secondary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-3" style="width:32px;height:32px;"><i class="fas fa-tasks text-secondary"></i></div><div><div class="fw-semibold">${(r.task_name ? r.project_name + ' • ' + r.task_name : r.project_name + ' • ' + (i18nDash.no_task || 'No task'))}</div></div>`;
const right = document.createElement('div');
right.className = 'badge bg-primary rounded-pill';
right.textContent = `${(r.total_hours ?? 0).toFixed(2)} ${(i18nDash.hours_suffix || 'h')}`;
item.appendChild(left);
item.appendChild(right);
list.appendChild(item);
});
container.innerHTML = '';
container.appendChild(list);
}
fetch('/api/analytics/today-by-task').then(r => r.json()).then(data => {
if (data && data.date) {
try { dateEl.textContent = new Date(data.date + 'T00:00:00').toLocaleDateString(); } catch(e) {}
}
renderRows((data && data.rows) || []);
}).catch(() => {
container.innerHTML = `<div class="text-muted">${i18nDash.no_data || 'No time tracked yet today'}</div>`;
});
})();
// Validate start timer submission
const startForm = document.querySelector('#startTimerModal form');
if (startForm) {