mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-18 20:29:44 -05:00
689700d260
- 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>
475 lines
22 KiB
HTML
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 %}
|