Files
TimeTracker/app/templates/timer/timer_page.html
T
Dries Peeters 689700d260 fix(timer): load tasks when selecting project on Timer and Log Time
- Use /api/projects/<id>/tasks instead of /api/tasks?project_id= so task
  loading matches the working Edit Logged Time flow.
- Add credentials: 'same-origin' and response.ok checks for reliable
  session auth and error handling.
- Render JS-embedded i18n strings with |tojson to avoid breakage in
  non-English locales.

Fixes #480

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-02 14:11:02 +01:00

475 lines
22 KiB
HTML

{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% from "components/client_select.html" import client_select %}
{% block content %}
{% set breadcrumbs = [
{'text': _('Timer')}
] %}
{{ page_header(
icon_class='fas fa-stopwatch',
title_text=_('Timer'),
subtitle_text=_('Track your time with a visual timer'),
breadcrumbs=breadcrumbs,
actions_html=None
) }}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Main Timer Section -->
<div class="lg:col-span-2 space-y-6">
<!-- Visual Timer with Progress Ring -->
<div class="bg-card-light dark:bg-card-dark p-8 rounded-lg shadow-md">
{% if active_timer %}
<div class="flex flex-col items-center justify-center">
<!-- Progress Ring -->
<div class="relative w-64 h-64 mb-6">
<svg class="w-64 h-64 transform -rotate-90" viewBox="0 0 100 100">
<!-- Background circle -->
<circle cx="50" cy="50" r="45" stroke="currentColor"
class="text-gray-200 dark:text-gray-700"
stroke-width="8" fill="none" />
<!-- Progress circle -->
{% set elapsed_seconds = active_timer.current_duration_seconds %}
{% set max_seconds = 8 * 3600 %}
{% set progress = (elapsed_seconds / max_seconds * 100) if max_seconds > 0 else 0 %}
{% set circumference = 2 * 3.14159 * 45 %}
{% set stroke_dashoffset = circumference - (progress / 100 * circumference) %}
<circle cx="50" cy="50" r="45" stroke="currentColor"
class="text-primary transition-all duration-1000"
stroke-width="8" fill="none"
stroke-dasharray="{{ circumference }}"
stroke-dashoffset="{{ stroke_dashoffset }}"
id="timer-progress-ring" />
</svg>
<!-- Timer Display -->
<div class="absolute inset-0 flex flex-col items-center justify-center">
<div class="text-4xl font-bold text-text-light dark:text-text-dark mb-2" id="timer-display">
{{ active_timer.duration_formatted }}
</div>
<div class="text-sm text-text-muted-light dark:text-text-muted-dark">
{% if active_timer.project %}
{{ active_timer.project.name }}
{% if active_timer.task %}
- {{ active_timer.task.name }}
{% endif %}
{% elif active_timer.client %}
{{ active_timer.client.name }} <span class="text-xs text-gray-500">({{ _('Direct') }})</span>
{% else %}
{{ _('No project') }}
{% endif %}
</div>
{% if active_timer.notes %}
<div class="text-xs text-text-muted-light dark:text-text-muted-dark mt-2 max-w-xs text-center">
{{ active_timer.notes }}
</div>
{% endif %}
</div>
</div>
<!-- Timer Controls -->
<div class="flex gap-4">
<form action="{{ url_for('timer.stop_timer') }}" method="POST" class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="bg-red-500 text-white px-6 py-3 rounded-lg hover:bg-red-600 transition-colors shadow-md">
<i class="fas fa-stop mr-2"></i>{{ _('Stop Timer') }}
</button>
</form>
</div>
<!-- Duration Estimation -->
<div class="mt-6 p-4 bg-background-light dark:bg-background-dark rounded-lg w-full max-w-md">
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark mb-2">{{ _('Estimated Completion') }}</h3>
<div class="text-lg font-semibold text-text-light dark:text-text-dark" id="estimated-completion">
{{ _('Calculating...') }}
</div>
<div class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">
{{ _('Based on average session duration') }}
</div>
</div>
</div>
{% else %}
<!-- No Active Timer -->
<div class="flex flex-col items-center justify-center py-12">
<div class="w-64 h-64 mb-6 flex items-center justify-center">
<svg class="w-64 h-64 transform -rotate-90" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="45" stroke="currentColor"
class="text-gray-200 dark:text-gray-700"
stroke-width="8" fill="none" />
</svg>
<div class="absolute text-4xl font-bold text-text-muted-light dark:text-text-muted-dark">
00:00:00
</div>
</div>
<p class="text-text-muted-light dark:text-text-muted-dark mb-6">{{ _('No active timer. Start one below!') }}</p>
</div>
{% endif %}
</div>
<!-- Quick Project/Task Selection -->
{% if not active_timer %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow-md">
<h2 class="text-lg font-semibold mb-4">{{ _('Start New Timer') }}</h2>
<form action="{{ url_for('timer.start_timer') }}" method="POST" id="timer-start-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="project_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ _('Project') }}
</label>
<select name="project_id" id="project_id" class="form-input w-full">
<option value="">{{ _('Select a project (optional)') }}</option>
{% for project in projects %}
<option value="{{ project.id }}">{{ project.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ _('Client') }}
</label>
{{ client_select('client_id', clients, required=False, only_one_client=only_one_client|default(false), single_client=single_client) }}
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">
{{ _('Select either a project or a client') }}
</p>
</div>
</div>
<div>
<label for="task_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ _('Task') }} <span class="text-text-muted-light dark:text-text-muted-dark">({{ _('Optional') }})</span>
</label>
<select name="task_id" id="task_id" class="form-input w-full">
<option value="">{{ _('No task') }}</option>
</select>
</div>
<div>
<label for="notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ _('Notes') }} <span class="text-text-muted-light dark:text-text-muted-dark">({{ _('Optional') }})</span>
</label>
<textarea name="notes" id="notes" rows="3" class="form-input w-full" placeholder="{{ _('Add notes about what you\'re working on...') }}"></textarea>
</div>
{% if templates %}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ _('Or use a template') }}
</label>
<div class="space-y-2">
{% for template in templates %}
<button type="button"
onclick="applyTemplate({{ template.id }})"
class="w-full text-left p-3 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition">
<div class="flex items-center justify-between">
<div class="flex-1">
<div class="font-medium text-sm">{{ template.name }}</div>
{% if template.project %}
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">
<i class="fas fa-folder"></i> {{ template.project.name }}
{% if template.task %} → {{ template.task.name }}{% endif %}
</div>
{% endif %}
</div>
<i class="fas fa-chevron-right text-gray-400"></i>
</div>
</button>
{% endfor %}
<a href="{{ url_for('time_entry_templates.list_templates') }}"
class="block text-center text-sm text-blue-600 dark:text-blue-400 hover:underline pt-2">
View all templates →
</a>
</div>
</div>
{% endif %}
<button type="submit" class="bg-primary text-white px-6 py-3 rounded-lg hover:bg-primary/90 transition-colors shadow-md w-full">
<i class="fas fa-play mr-2"></i>Start Timer
</button>
</div>
</form>
</div>
{% endif %}
</div>
<!-- Sidebar -->
<div class="space-y-6">
<!-- Recent Projects Quick Access -->
{% if recent_projects %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow-md">
<h2 class="text-lg font-semibold mb-4">{{ _('Recent Projects') }}</h2>
<div class="space-y-2">
{% for project in recent_projects %}
<button type="button"
onclick="selectRecentProject({{ project.id }}, '{{ project.name|e }}')"
class="w-full text-left px-4 py-3 rounded-lg bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<div class="font-medium text-text-light dark:text-text-dark">{{ project.name }}</div>
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">
{% if project.client %}{{ project.client }}{% endif %}
</div>
</button>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Quick Stats -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow-md">
<h2 class="text-lg font-semibold mb-4">{{ _("Today's Stats") }}</h2>
<div class="space-y-3">
<div class="flex justify-between items-center">
<span class="text-text-muted-light dark:text-text-muted-dark">Total Hours</span>
<span class="text-lg font-semibold text-text-light dark:text-text-dark" id="today-total-hours"></span>
</div>
<div class="flex justify-between items-center">
<span class="text-text-muted-light dark:text-text-muted-dark">Active Timer</span>
<span class="text-lg font-semibold {% if active_timer %}text-green-600{% else %}text-text-muted-light dark:text-text-muted-dark{% endif %}">
{% if active_timer %}Running{% else %}Stopped{% endif %}
</span>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts_extra %}
<script>
// Load tasks when project is selected and sync with client selection
const projectSelectEl = document.getElementById('project_id');
const clientSelectEl = document.getElementById('client_id');
const taskSelectEl = document.getElementById('task_id');
const noTaskText = {{ _('No task')|tojson }};
const failedToLoadTasksText = {{ _('Failed to load tasks')|tojson }};
async function loadTasksForProject(projectId) {
if (!taskSelectEl) return;
if (!projectId) {
taskSelectEl.innerHTML = `<option value="">${noTaskText}</option>`;
taskSelectEl.disabled = false;
return;
}
try {
const response = await fetch(`/api/projects/${projectId}/tasks`, { credentials: 'same-origin' });
if (!response.ok) throw new Error(failedToLoadTasksText);
const data = await response.json();
const tasks = Array.isArray(data?.tasks) ? data.tasks : [];
taskSelectEl.innerHTML = `<option value="">${noTaskText}</option>`;
tasks.forEach(task => {
const option = document.createElement('option');
option.value = task.id;
option.textContent = task.name;
taskSelectEl.appendChild(option);
});
taskSelectEl.disabled = false;
} catch (error) {
console.error('Error loading tasks:', error);
taskSelectEl.innerHTML = `<option value="">${noTaskText}</option>`;
taskSelectEl.disabled = true;
}
}
const onlyOneClient = {{ 'true' if only_one_client|default(false) else 'false' }};
const singleClientId = {{ ('"' ~ single_client.id ~ '"') if single_client else 'null' }};
if (projectSelectEl && clientSelectEl) {
projectSelectEl.addEventListener('change', () => {
const pid = projectSelectEl.value;
if (pid && clientSelectEl) {
clientSelectEl.value = '';
} else if (onlyOneClient && singleClientId) {
clientSelectEl.value = singleClientId;
}
loadTasksForProject(pid);
});
clientSelectEl.addEventListener('change', () => {
const cid = clientSelectEl.value;
if (cid) {
if (projectSelectEl) {
projectSelectEl.value = '';
}
if (taskSelectEl) {
taskSelectEl.innerHTML = `<option value="">${noTaskText}</option>`;
taskSelectEl.disabled = true;
}
} else if (taskSelectEl) {
taskSelectEl.disabled = false;
}
});
}
// Form validation: ensure either project or client is selected
const timerStartForm = document.getElementById('timer-start-form');
if (timerStartForm) {
// Store original button state to restore if needed
const submitBtn = timerStartForm.querySelector('button[type="submit"]');
let originalButtonHTML = submitBtn ? submitBtn.innerHTML : null;
timerStartForm.addEventListener('submit', function(e) {
// Validate project or client selection
const projectVal = projectSelectEl ? projectSelectEl.value : '';
const clientVal = clientSelectEl ? clientSelectEl.value : '';
if (!projectVal && !clientVal) {
e.preventDefault();
e.stopImmediatePropagation(); // Stop other handlers from running
// Ensure button state is preserved
if (submitBtn && originalButtonHTML) {
submitBtn.innerHTML = originalButtonHTML;
submitBtn.disabled = false;
}
// Show error message using toast notification
const errorMsg = '{{ _("Please select either a project or a client") }}';
if (window.toastManager && typeof window.toastManager.error === 'function') {
window.toastManager.error(errorMsg, '{{ _("Error") }}', 5000);
} else {
alert(errorMsg);
}
// After showing error, ensure button is still in correct state
if (submitBtn && originalButtonHTML) {
submitBtn.innerHTML = originalButtonHTML;
submitBtn.disabled = false;
}
return false;
}
}, true); // Use capture phase to run before other handlers
}
// Select recent project
function selectRecentProject(projectId, projectName) {
const projectSelect = document.getElementById('project_id');
if (projectSelect) {
projectSelect.value = projectId;
projectSelect.dispatchEvent(new Event('change'));
}
}
// Update timer display every second
{% if active_timer %}
let timerInterval;
let startTime = new Date('{{ active_timer.start_time.isoformat() }}').getTime();
function updateTimer() {
const now = new Date().getTime();
const elapsed = Math.floor((now - startTime) / 1000);
const hours = Math.floor(elapsed / 3600);
const minutes = Math.floor((elapsed % 3600) / 60);
const seconds = elapsed % 60;
const display = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
const timerDisplay = document.getElementById('timer-display');
if (timerDisplay) {
timerDisplay.textContent = display;
}
// Update progress ring
const maxSeconds = 8 * 3600; // 8 hours
const progress = Math.min((elapsed / maxSeconds) * 100, 100);
const circumference = 2 * Math.PI * 45;
const strokeDashoffset = circumference - (progress / 100 * circumference);
const progressRing = document.getElementById('timer-progress-ring');
if (progressRing) {
progressRing.style.strokeDashoffset = strokeDashoffset;
}
// Update estimated completion
updateEstimatedCompletion(elapsed);
}
function updateEstimatedCompletion(elapsedSeconds) {
// Simple estimation: assume average session is 2 hours
// This could be improved with historical data
const avgSessionSeconds = 2 * 3600;
const estimatedTotal = avgSessionSeconds;
const remaining = Math.max(0, estimatedTotal - elapsedSeconds);
if (remaining > 0) {
const hours = Math.floor(remaining / 3600);
const minutes = Math.floor((remaining % 3600) / 60);
const completionEl = document.getElementById('estimated-completion');
if (completionEl) {
completionEl.textContent = `${hours}h ${minutes}m remaining`;
}
} else {
const completionEl = document.getElementById('estimated-completion');
if (completionEl) {
completionEl.textContent = 'Session complete';
}
}
}
// Start timer update
timerInterval = setInterval(updateTimer, 1000);
updateTimer(); // Initial update
// Load today's total hours
fetch('/api/timer/status')
.then(response => response.json())
.then(data => {
// You might want to add an API endpoint for today's hours
// For now, we'll just show the active timer status
})
.catch(error => {
console.error('Error loading timer status:', error);
});
{% endif %}
// Template application function
window.applyTemplate = async function(templateId) {
try {
const response = await fetch(`/api/templates/${templateId}`, { credentials: 'same-origin' });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const template = await response.json();
// Get form elements
const projectSelect = document.getElementById('project_id');
const taskSelect = document.getElementById('task_id');
const notesField = document.getElementById('notes');
if (!projectSelect || !taskSelect || !notesField) {
throw new Error('Form elements not found');
}
// Apply template values to form
if (template.project_id) {
projectSelect.value = template.project_id;
// Trigger change event to load tasks
projectSelect.dispatchEvent(new Event('change'));
// Wait a bit for tasks to load, then select task
setTimeout(() => {
if (template.task_id) {
taskSelect.value = template.task_id;
}
}, 300);
}
if (template.default_notes) {
notesField.value = template.default_notes;
}
// Mark template as used
fetch(`/api/templates/${templateId}/use`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
}
}).catch(() => {}); // Silently fail if marking fails
} catch (error) {
console.error('Error applying template:', error);
alert('Failed to load template. Please try again.');
}
};
</script>
{% endblock %}