mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-07 03:59:48 -06:00
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:
@@ -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
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user