Files
TimeTracker/app/templates/timer/manual_entry.html
T
Dries Peeters 3b020e6c05 fix(timer): recalculate worked time after date/time commit (fixes #559)
Defer manual entry worked-time recalculation to the next microtask so
the DOM has the latest start/end date and time before reading. Add
input listeners so recalculation runs on every date/time change.

Fixes incorrect duration when end date is in the past (e.g. yesterday)
until the user reselected the end date.
2026-03-12 21:38:21 +01:00

1109 lines
56 KiB
HTML

{% extends "base.html" %}
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge, form_group, form_select, form_textarea, form_checkbox, form_date, form_section, form_actions %}
{% from "components/client_select.html" import client_select %}
{% block content %}
{% set breadcrumbs = [
{'text': _('Time Tracking')},
{'text': _('Log Time') if not is_duplicate else _('Duplicate Entry')}
] %}
{{ page_header(
icon_class='fas fa-clock',
title_text=_('Duplicate Time Entry') if is_duplicate else _('Log Time Manually'),
subtitle_text=_('Create a copy of a previous entry with new times') if is_duplicate else _('Create a new time entry'),
breadcrumbs=breadcrumbs,
actions_html=None
) }}
{% if is_duplicate and original_entry %}
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl p-4 mb-6 max-w-3xl mx-auto">
<div class="flex items-start gap-3">
<i class="fas fa-info-circle text-blue-600 dark:text-blue-400 mt-1"></i>
<div>
<p class="text-sm text-blue-800 dark:text-blue-200">
<strong>{{ _('Duplicating entry:') }}</strong>
{% if original_entry.project %}
{{ original_entry.project.name }}{% if original_entry.task %} - {{ original_entry.task.name }}{% endif %}
{% elif original_entry.client %}
{{ original_entry.client.name }} <span class="text-xs text-gray-500">({{ _('Direct') }})</span>
{% endif %}
</p>
<p class="text-xs text-blue-600 dark:text-blue-300 mt-1">
{{ _('Original:') }} {{ original_entry.start_time|user_datetime }} {{ _('to') }} {{ original_entry.end_time|user_datetime if original_entry.end_time else _('N/A') }} ({{ original_entry.duration_formatted }})
</p>
</div>
</div>
</div>
{% endif %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-xl shadow-lg max-w-3xl mx-auto">
<form method="POST" action="{{ url_for('timer.manual_entry') }}" autocomplete="off" novalidate data-validate-form id="manualEntryForm"
data-require-task="{{ 'true' if getattr(settings, 'time_entry_require_task', false) else 'false' }}"
data-require-description="{{ 'true' if getattr(settings, 'time_entry_require_description', false) else 'false' }}"
data-description-min-length="{{ getattr(settings, 'time_entry_description_min_length', 20) }}"
data-description-min-msg="{{ _('Description must be at least %(min)s characters', min=getattr(settings, 'time_entry_description_min_length', 20))|e }}"
data-break-after-hours-1="{{ getattr(settings, 'break_after_hours_1', none) or 6 }}"
data-break-minutes-1="{{ getattr(settings, 'break_minutes_1', none) or 30 }}"
data-break-after-hours-2="{{ getattr(settings, 'break_after_hours_2', none) or 9 }}"
data-break-minutes-2="{{ getattr(settings, 'break_minutes_2', none) or 45 }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="worked_time_mode" id="worked_time_mode" value="{{ prefill_worked_time_mode or '' }}">
<div class="space-y-8">
{# Section: Project & task #}
<section class="pb-6 border-b border-border-light dark:border-border-dark">
<h2 class="form-section-title">
<i class="fas fa-folder text-primary"></i>{{ _('Project & task') }}
</h2>
<div class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="project_id" class="block text-sm font-medium text-text-light dark:text-text-dark">{{ _('Project') }}</label>
<select name="project_id" id="project_id" class="form-input">
<option value="">{{ _('Select a project (optional)') }}</option>
{% for project in projects %}
<option value="{{ project.id }}" {% if selected_project_id == project.id %}selected{% endif %}>{{ project.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="client_id" class="block text-sm font-medium text-text-light dark:text-text-dark">{{ _('Client') }}</label>
{# Single client: show as normal dropdown, pre-select only when client_id is in URL (parameter) #}
{{ client_select('client_id', clients, selected_id=selected_client_id, required=False, only_one_client=false, single_client=none) }}
<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 class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="task_id" class="block text-sm font-medium text-text-light dark:text-text-dark">{% if getattr(settings, 'time_entry_require_task', false) %}{{ _('Task') }} *{% else %}{{ _('Task (optional)') }}{% endif %}</label>
<select name="task_id" id="task_id" class="form-input" data-selected-task-id="{{ selected_task_id or '' }}" {% if not selected_project_id %}disabled{% endif %}>
<option value="">{{ _('No task') }}</option>
</select>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Tasks load after selecting a project') }}</p>
</div>
</div>
</div>
</section>
{# Section: Date & time #}
<section class="pb-6 border-b border-border-light dark:border-border-dark">
<h2 class="form-section-title">
<i class="fas fa-calendar-alt text-primary"></i>{{ _('Date & time') }}
</h2>
<div class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="start_date" class="block text-sm font-medium text-text-light dark:text-text-dark">{{ _('Start Date') }}</label>
<input type="date" name="start_date" id="start_date" class="form-input user-date-input" value="{{ prefill_start_date or '' }}">
</div>
<div>
<label for="end_date" class="block text-sm font-medium text-text-light dark:text-text-dark">{{ _('End Date') }}</label>
<input type="date" name="end_date" id="end_date" class="form-input user-date-input" value="{{ prefill_end_date or '' }}">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="start_time" class="block text-sm font-medium text-text-light dark:text-text-dark">{{ _('Start Time') }}</label>
<input type="time" name="start_time" id="start_time" class="form-input" value="{{ prefill_start_time or '' }}">
</div>
<div>
<label for="end_time" class="block text-sm font-medium text-text-light dark:text-text-dark">{{ _('End Time') }}</label>
<input type="time" name="end_time" id="end_time" class="form-input" value="{{ prefill_end_time or '' }}">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="worked_time" class="block text-sm font-medium text-text-light dark:text-text-dark">{{ _('Worked Time') }}</label>
<input type="text" name="worked_time" id="worked_time" class="form-input" inputmode="numeric" placeholder="HH:MM" autocomplete="off" value="{{ prefill_worked_time or '' }}">
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Optional: enter HH:MM for duration. You can combine with Start Date/Time to log time on a specific day.') }}</p>
</div>
<div>
<label for="break_time" class="block text-sm font-medium text-text-light dark:text-text-dark">{{ _('Break') }}</label>
<div class="flex items-center gap-2">
<input type="text" name="break_time" id="break_time" class="form-input flex-1" inputmode="numeric" placeholder="HH:MM" autocomplete="off" value="{{ prefill_break_time or '' }}">
<button type="button" id="suggestBreakBtn" class="px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 text-text-light dark:text-text-dark text-sm hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors" title="{{ _('Suggest break based on duration (e.g. >6h: 30 min, >9h: 45 min)') }}">{{ _('Suggest') }}</button>
</div>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Optional: break duration (HH:MM). Subtracted from total time to get worked duration.') }}</p>
</div>
</div>
</div>
</section>
{# Section: Details #}
<section>
<h2 class="text-sm font-semibold text-text-light dark:text-text-dark uppercase tracking-wide flex items-center gap-2 mb-4">
<i class="fas fa-align-left text-primary"></i>{{ _('Details') }}
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="md:col-span-2">
<label for="notes" class="block text-sm font-medium text-text-light dark:text-text-dark mb-2">{% if getattr(settings, 'time_entry_require_description', false) %}{{ _('Notes') }} *{% else %}{{ _('Notes') }}{% endif %}</label>
<div class="markdown-editor-wrapper">
<textarea name="notes" id="notes" class="hidden">{{ prefill_notes or '' }}</textarea>
<div id="notes_editor"></div>
</div>
</div>
<div class="space-y-4">
<div>
<label for="tags" class="block text-sm font-medium text-text-light dark:text-text-dark">{{ _('Tags') }}</label>
<input type="text" name="tags" id="tags" class="form-input" placeholder="{{ _('tag1, tag2') }}" value="{{ prefill_tags or '' }}">
</div>
<div class="flex items-center gap-3">
<input type="checkbox" id="billable" name="billable" class="h-5 w-5 rounded border-gray-300 text-primary focus:ring-0" {% if prefill_billable is not defined or prefill_billable %}checked{% endif %}>
<label for="billable" class="text-sm font-medium text-text-light dark:text-text-dark">{{ _('Billable') }}</label>
</div>
</div>
</div>
</section>
</div>
<div class="mt-8 border-t border-border-light dark:border-border-dark pt-6 flex flex-col sm:flex-row justify-end gap-3">
<button type="reset" class="w-full sm:w-auto px-5 py-2.5 rounded-lg border border-border-light dark:border-border-dark text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark font-medium transition-all">{{ _('Clear') }}</button>
<button type="submit" class="w-full sm:w-auto bg-primary hover:bg-primary-dark text-white px-5 py-2.5 rounded-lg font-semibold shadow-lg hover:shadow-xl transition-all duration-200">{{ _('Log Time') }}</button>
</div>
</form>
</div>
<script>
(function() {
// Issue #557: avoid loading Toast UI Editor on mobile to prevent browser freeze
window.isMobileView = window.matchMedia('(max-width: 767px)').matches;
if (window.isMobileView) {
var notes = document.getElementById('notes');
var editorEl = document.getElementById('notes_editor');
if (notes) {
notes.classList.remove('hidden');
notes.classList.add('form-input');
notes.setAttribute('rows', '5');
}
if (editorEl) editorEl.style.display = 'none';
}
})();
</script>
<script>
document.addEventListener('DOMContentLoaded', async function(){
// Default values for date/time to now (use local date, not UTC, for timezones ahead of UTC)
const now = new Date();
const yyyy = now.getFullYear();
const monthPad = String(now.getMonth() + 1).padStart(2, '0');
const dayPad = String(now.getDate()).padStart(2, '0');
const today = `${yyyy}-${monthPad}-${dayPad}`;
const hh = String(now.getHours()).padStart(2,'0');
const mm = String(now.getMinutes()).padStart(2,'0');
const startDate = document.getElementById('start_date');
const endDate = document.getElementById('end_date');
const startTime = document.getElementById('start_time');
const endTime = document.getElementById('end_time');
const workedTime = document.getElementById('worked_time');
// Only auto-fill start/end if user isn't trying to do duration-only.
const hasWorkedPrefill = !!(workedTime && String(workedTime.value || '').trim());
if (!hasWorkedPrefill) {
if (startDate && !startDate.value) startDate.value = today;
if (endDate && !endDate.value) endDate.value = today;
if (startTime && !startTime.value) startTime.value = `${hh}:${mm}`;
if (endTime && !endTime.value) endTime.value = `${hh}:${mm}`;
}
// Worked time helper (duration)
const workedTimeMode = document.getElementById('worked_time_mode');
let suppressTimeSync = false;
let workedTimeUserEdited = false;
let timeFieldsUserEdited = false;
function setDurationOnlyMode(on) {
// No longer disable date/time when duration is entered: allow combined use
// (e.g. "2:30" + "yesterday" = 2.5h on that date). Only track mode for backend.
if (workedTimeMode) workedTimeMode.value = on ? 'explicit' : (workedTimeMode.value || '');
}
function ensureStartEndDefaultsIfEmpty() {
if (startDate && !startDate.value) startDate.value = today;
if (endDate && !endDate.value) endDate.value = today;
if (startTime && !startTime.value) startTime.value = `${hh}:${mm}`;
if (endTime && !endTime.value) endTime.value = `${hh}:${mm}`;
}
function parseWorkedMinutes(raw) {
const s = String(raw || '').trim();
if (!s) return null;
const m = s.match(/^(\d{1,3}):([0-5]\d)$/);
if (!m) return null;
const hours = parseInt(m[1], 10);
const minutes = parseInt(m[2], 10);
if (Number.isNaN(hours) || Number.isNaN(minutes)) return null;
return hours * 60 + minutes;
}
function formatWorkedMinutes(totalMinutes) {
const mins = Math.max(0, Math.floor(totalMinutes || 0));
const h = Math.floor(mins / 60);
const m = mins % 60;
return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}`;
}
function getStartEnd() {
if (!startDate || !startTime || !endDate || !endTime) return { start: null, end: null };
if (!startDate.value || !startTime.value || !endDate.value || !endTime.value) return { start: null, end: null };
const start = new Date(`${startDate.value}T${startTime.value}:00`);
const end = new Date(`${endDate.value}T${endTime.value}:00`);
if (isNaN(start.getTime()) || isNaN(end.getTime())) return { start: null, end: null };
return { start, end };
}
function setEndFromDate(end) {
const yyyy = end.getFullYear();
const mm2 = String(end.getMonth() + 1).padStart(2,'0');
const dd = String(end.getDate()).padStart(2,'0');
const hh2 = String(end.getHours()).padStart(2,'0');
const mi2 = String(end.getMinutes()).padStart(2,'0');
if (endDate) endDate.value = `${yyyy}-${mm2}-${dd}`;
if (endTime) endTime.value = `${hh2}:${mi2}`;
}
function updateWorkedFromStartEnd() {
if (!workedTime || suppressTimeSync) return;
const { start, end } = getStartEnd();
if (!start || !end) return;
const diffMinutes = Math.round((end.getTime() - start.getTime()) / 60000);
if (diffMinutes <= 0) return;
suppressTimeSync = true;
workedTime.value = formatWorkedMinutes(diffMinutes);
// Auto-calculated; do not mark as explicit duration override.
if (!workedTimeUserEdited && workedTimeMode) workedTimeMode.value = '';
suppressTimeSync = false;
}
function updateEndFromStartWorked() {
if (!workedTime || suppressTimeSync) return;
if (!startDate || !startTime) return;
if (!startDate.value || !startTime.value) return;
const minutes = parseWorkedMinutes(workedTime.value);
if (minutes == null) return;
const start = new Date(`${startDate.value}T${startTime.value}:00`);
if (isNaN(start.getTime())) return;
const end = new Date(start.getTime() + minutes * 60000);
suppressTimeSync = true;
setEndFromDate(end);
if (workedTimeMode) workedTimeMode.value = workedTimeUserEdited ? 'explicit' : '';
suppressTimeSync = false;
}
// Suggest break from duration (default rules: >6h = 30 min, >9h = 45 min)
const suggestBreakBtn = document.getElementById('suggestBreakBtn');
const breakTimeInput = document.getElementById('break_time');
if (suggestBreakBtn && breakTimeInput && form) {
suggestBreakBtn.addEventListener('click', function() {
let durationHours = 0;
const workedMins = parseWorkedMinutes(workedTime && workedTime.value);
if (workedMins != null) {
durationHours = workedMins / 60;
} else {
const { start, end } = getStartEnd();
if (start && end && end > start) {
durationHours = (end.getTime() - start.getTime()) / (60 * 60 * 1000);
}
}
const h1 = parseFloat(form.dataset.breakAfterHours1 || '6');
const m1 = parseInt(form.dataset.breakMinutes1 || '30', 10);
const h2 = parseFloat(form.dataset.breakAfterHours2 || '9');
const m2 = parseInt(form.dataset.breakMinutes2 || '45', 10);
let suggestedMinutes = 0;
if (durationHours >= h2) suggestedMinutes = m2;
else if (durationHours >= h1) suggestedMinutes = m1;
breakTimeInput.value = formatWorkedMinutes(suggestedMinutes);
});
}
function onStartChange() {
if (suppressTimeSync) return;
// If worked time is valid, keep duration constant and move end; otherwise just recompute worked time.
const minutes = workedTime ? parseWorkedMinutes(workedTime.value) : null;
if (minutes != null) updateEndFromStartWorked();
else updateWorkedFromStartEnd();
}
// Defer recalculation so the DOM has the latest date/time values (Issue #559)
let scheduledWorkedTime = false;
let pendingStart = false;
let pendingEnd = false;
function scheduleWorkedTimeUpdate(isStart) {
if (isStart) pendingStart = true; else pendingEnd = true;
if (scheduledWorkedTime) return;
scheduledWorkedTime = true;
queueMicrotask(function() {
scheduledWorkedTime = false;
const runStart = pendingStart;
const runEnd = pendingEnd;
pendingStart = false;
pendingEnd = false;
if (runStart) onStartChange();
if (runEnd) updateWorkedFromStartEnd();
});
}
// Dynamic task loading when a project is chosen
const projectSelect = document.getElementById('project_id');
const clientSelect = document.getElementById('client_id');
const taskSelect = document.getElementById('task_id');
const noTaskText = {{ _('No task')|tojson }};
const failedToLoadTasksText = {{ _('Failed to load tasks')|tojson }};
const scriptRoot = {{ request.script_root|default('')|tojson }};
// Task loading: when project is selected, clear client (mutual exclusivity) and load tasks
if (projectSelect && taskSelect) {
projectSelect.addEventListener('change', function() {
const projectId = this.value;
if (clientSelect) clientSelect.value = '';
loadTasks(projectId);
});
}
// Client/project mutual exclusivity (when client select exists)
if (clientSelect) {
clientSelect.addEventListener('change', function() {
if (this.value) {
if (projectSelect) projectSelect.value = '';
if (taskSelect) {
taskSelect.innerHTML = '<option value="">' + noTaskText + '</option>';
taskSelect.disabled = true;
}
}
});
}
function buildTasksUrl(projectId) {
const pid = String(projectId || '').trim();
if (!pid) return '';
return (scriptRoot || '') + '/api/projects/' + encodeURIComponent(pid) + '/tasks';
}
async function loadTasks(projectId){
if (!taskSelect) return;
if (!projectId){
taskSelect.innerHTML = '<option value="">' + noTaskText + '</option>';
taskSelect.disabled = true;
return;
}
const url = buildTasksUrl(projectId);
try{
const resp = await fetch(url, {
credentials: 'same-origin',
headers: { 'Accept': 'application/json' },
cache: 'no-store'
});
const ct = (resp.headers.get('content-type') || '').toLowerCase();
const isJson = ct.includes('application/json');
// Detect auth redirect: got HTML instead of JSON (session expired -> login page) (Issue #489)
if (!isJson) {
const sessionExpiredMsg = {{ _("Session may have expired. Please refresh the page and try again.")|tojson }};
if (window.toastManager && typeof window.toastManager.error === 'function') {
window.toastManager.error(sessionExpiredMsg, {{ _("Error")|tojson }}, 6000);
} else {
alert(sessionExpiredMsg);
}
taskSelect.innerHTML = '<option value="">' + noTaskText + '</option>';
taskSelect.disabled = true;
return;
}
if (!resp.ok) {
if (resp.status === 404) {
const projectNotFoundText = {{ _('Project not found or inactive')|tojson }};
if (window.toastManager && typeof window.toastManager.error === 'function') {
window.toastManager.error(projectNotFoundText, {{ _("Error")|tojson }}, 5000);
} else {
alert(projectNotFoundText);
}
taskSelect.innerHTML = '<option value="">' + noTaskText + '</option>';
taskSelect.disabled = true;
return;
}
throw new Error(failedToLoadTasksText + ' (HTTP ' + resp.status + ')');
}
const data = await resp.json();
const tasks = Array.isArray(data?.tasks) ? data.tasks : [];
taskSelect.innerHTML = '<option value="">' + noTaskText + '</option>';
tasks.forEach(t => {
const opt = document.createElement('option');
opt.value = String(t.id);
opt.textContent = t.name;
taskSelect.appendChild(opt);
});
const pre = taskSelect.getAttribute('data-selected-task-id');
if (pre){
const found = Array.from(taskSelect.options).some(o => o.value === pre);
if (found) taskSelect.value = pre;
taskSelect.setAttribute('data-selected-task-id','');
}
taskSelect.disabled = false;
}catch(e){
taskSelect.innerHTML = '<option value="">' + noTaskText + '</option>';
taskSelect.disabled = true;
try {
const msg = e && e.message ? e.message : (failedToLoadTasksText || 'Failed to load tasks');
if (window.toastManager && typeof window.toastManager.error === 'function') {
window.toastManager.error(msg, {{ _("Error")|tojson }}, 5000);
} else if (window.toastManager && typeof window.toastManager.show === 'function') {
window.toastManager.show(msg, 'error');
} else {
alert(msg);
}
} catch (_) {}
if (typeof console !== 'undefined' && console.error) {
console.error('[Log Time] Task dropdown failed. Project:', projectId, 'URL:', url, 'Error:', e);
}
}
}
if (projectSelect && projectSelect.value) {
loadTasks(projectSelect.value);
}
// Keep worked time in sync with start/end inputs (date/time stay editable so user can combine duration + date)
if (workedTime) {
if (String(workedTime.value || '').trim()) {
workedTimeUserEdited = true;
setDurationOnlyMode(true);
}
updateWorkedFromStartEnd();
workedTime.addEventListener('change', function() {
workedTimeUserEdited = true;
const mins = parseWorkedMinutes(workedTime.value);
if (mins != null) setDurationOnlyMode(true);
updateEndFromStartWorked();
});
workedTime.addEventListener('input', function() {
if (!suppressTimeSync) workedTimeUserEdited = true;
const raw = String(workedTime.value || '').trim();
if (raw && workedTimeMode) workedTimeMode.value = 'explicit';
});
workedTime.addEventListener('blur', function() {
// Normalize formatting if valid
const mins = parseWorkedMinutes(workedTime.value);
if (mins != null) workedTime.value = formatWorkedMinutes(mins);
});
}
if (startDate) {
startDate.addEventListener('change', () => { timeFieldsUserEdited = true; scheduleWorkedTimeUpdate(true); });
startDate.addEventListener('input', () => { timeFieldsUserEdited = true; scheduleWorkedTimeUpdate(true); });
startDate.addEventListener('focus', () => { if (startDate.disabled) { setDurationOnlyMode(false); ensureStartEndDefaultsIfEmpty(); } });
}
if (startTime) {
startTime.addEventListener('change', () => { timeFieldsUserEdited = true; scheduleWorkedTimeUpdate(true); });
startTime.addEventListener('input', () => { timeFieldsUserEdited = true; scheduleWorkedTimeUpdate(true); });
startTime.addEventListener('focus', () => { if (startTime.disabled) { setDurationOnlyMode(false); ensureStartEndDefaultsIfEmpty(); } });
}
if (endDate) {
endDate.addEventListener('change', () => { timeFieldsUserEdited = true; scheduleWorkedTimeUpdate(false); });
endDate.addEventListener('input', () => { timeFieldsUserEdited = true; scheduleWorkedTimeUpdate(false); });
endDate.addEventListener('focus', () => { if (endDate.disabled) { setDurationOnlyMode(false); ensureStartEndDefaultsIfEmpty(); } });
}
if (endTime) {
endTime.addEventListener('change', () => { timeFieldsUserEdited = true; scheduleWorkedTimeUpdate(false); });
endTime.addEventListener('input', () => { timeFieldsUserEdited = true; scheduleWorkedTimeUpdate(false); });
endTime.addEventListener('focus', () => { if (endTime.disabled) { setDurationOnlyMode(false); ensureStartEndDefaultsIfEmpty(); } });
}
// Form validation: ensure either project or client is selected and validate time range / duration-only
// Use capture phase to run before other handlers
const form = document.getElementById('manualEntryForm');
if (form) {
// Store original button state to restore if needed
const submitBtn = form.querySelector('button[type="submit"]');
let originalButtonHTML = submitBtn ? submitBtn.innerHTML : null;
form.addEventListener('submit', function(e) {
// Validate project or client selection
const projectVal = projectSelect ? projectSelect.value : '';
const clientVal = clientSelect ? clientSelect.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")|tojson }};
if (window.toastManager && typeof window.toastManager.error === 'function') {
window.toastManager.error(errorMsg, {{ _("Error")|tojson }}, 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;
}
// Validate time entry requirements (task, description)
const requireTask = form.dataset.requireTask === 'true';
const requireDescription = form.dataset.requireDescription === 'true';
const descMinLen = parseInt(form.dataset.descriptionMinLength || '20', 10);
if (projectVal && requireTask) {
const taskVal = taskSelect ? taskSelect.value : '';
if (!taskVal) {
e.preventDefault();
e.stopImmediatePropagation();
if (submitBtn && originalButtonHTML) {
submitBtn.innerHTML = originalButtonHTML;
submitBtn.disabled = false;
}
const errorMsg = {{ _("A task must be selected when logging time for a project")|tojson }};
if (window.toastManager && typeof window.toastManager.error === 'function') {
window.toastManager.error(errorMsg, {{ _("Error")|tojson }}, 5000);
} else {
alert(errorMsg);
}
return false;
}
}
if (requireDescription) {
const notesEl = document.getElementById('notes');
let notesVal = notesEl ? notesEl.value : '';
if (window.mdEditor && typeof window.mdEditor.getMarkdown === 'function') {
try { notesVal = window.mdEditor.getMarkdown(); } catch (err) {}
}
const notesTrimmed = (notesVal || '').trim();
if (!notesTrimmed) {
e.preventDefault();
e.stopImmediatePropagation();
if (submitBtn && originalButtonHTML) {
submitBtn.innerHTML = originalButtonHTML;
submitBtn.disabled = false;
}
const errorMsg = {{ _("A description is required when logging time")|tojson }};
if (window.toastManager && typeof window.toastManager.error === 'function') {
window.toastManager.error(errorMsg, {{ _("Error")|tojson }}, 5000);
} else {
alert(errorMsg);
}
return false;
}
if (notesTrimmed.length < descMinLen) {
e.preventDefault();
e.stopImmediatePropagation();
if (submitBtn && originalButtonHTML) {
submitBtn.innerHTML = originalButtonHTML;
submitBtn.disabled = false;
}
const errorMsg = form.dataset.descriptionMinMsg || ('Description must be at least ' + descMinLen + ' characters');
if (window.toastManager && typeof window.toastManager.error === 'function') {
window.toastManager.error(errorMsg, {{ _("Error")|tojson }}, 5000);
} else {
alert(errorMsg);
}
return false;
}
}
// Validate that either full start/end is present, or a worked_time duration is provided.
const startDate = document.getElementById('start_date');
const startTime = document.getElementById('start_time');
const endDate = document.getElementById('end_date');
const endTime = document.getElementById('end_time');
const wt = document.getElementById('worked_time');
const hasAllTimes = !!(startDate && startTime && endDate && endTime && startDate.value && startTime.value && endDate.value && endTime.value);
const workedMinutes = wt ? parseWorkedMinutes(wt.value) : null;
const hasWorked = workedMinutes != null;
if (!hasAllTimes && !hasWorked) {
e.preventDefault();
e.stopImmediatePropagation();
if (submitBtn && originalButtonHTML) {
submitBtn.innerHTML = originalButtonHTML;
submitBtn.disabled = false;
}
const errorMsg = {{ _("Please provide either start/end date+time or a worked time duration (HH:MM).")|tojson }};
if (window.toastManager && typeof window.toastManager.error === 'function') {
window.toastManager.error(errorMsg, {{ _("Error")|tojson }}, 5000);
} else {
alert(errorMsg);
}
return false;
}
// If we have start+end, validate the range. If duration-only, backend will compute range.
if (hasAllTimes) {
const start = new Date(`${startDate.value}T${startTime.value}:00`);
const end = new Date(`${endDate.value}T${endTime.value}:00`);
if (!isNaN(start.getTime()) && !isNaN(end.getTime())) {
if (end <= start) {
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 = {{ _("End time must be after start time")|tojson }};
if (window.toastManager && typeof window.toastManager.error === 'function') {
window.toastManager.error(errorMsg, {{ _("Error")|tojson }}, 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
}
// Apply Time Entry Template if provided via sessionStorage or query param
// Skip template application when duplicating an entry to preserve the original entry's task
const isDuplicating = {{ 'true' if is_duplicate else 'false' }};
if (!isDuplicating) {
try {
let tpl = null;
const raw = sessionStorage.getItem('activeTemplate');
if (raw) {
try { tpl = JSON.parse(raw); } catch(_) { tpl = null; }
}
if (!tpl) {
const params = new URLSearchParams(window.location.search);
const tplId = params.get('template');
if (tplId) {
try {
const resp = await fetch(`/api/templates/${tplId}`);
if (resp.ok) tpl = await resp.json();
} catch(_) {}
}
}
if (tpl && typeof tpl === 'object') {
// Preselect project and task
if (tpl.project_id && projectSelect) {
projectSelect.value = String(tpl.project_id);
if (clientSelect) clientSelect.value = '';
// Preselect task after load
if (taskSelect) {
taskSelect.setAttribute('data-selected-task-id', tpl.task_id ? String(tpl.task_id) : '');
}
await loadTasks(projectSelect.value);
}
// Notes, tags, billable
const notes = document.getElementById('notes');
const tagsInput = document.getElementById('tags');
const billable = document.getElementById('billable');
if (notes && tpl.default_notes) {
notes.value = tpl.default_notes;
// Update markdown editor if it exists
if (window.mdEditor && typeof window.mdEditor.setMarkdown === 'function') {
try {
window.mdEditor.setMarkdown(tpl.default_notes);
} catch (e) {}
}
}
if (tagsInput && tpl.tags) tagsInput.value = tpl.tags;
if (billable != null && typeof tpl.billable === 'boolean') billable.checked = !!tpl.billable;
// Duration → set end time relative to start if provided
const minutes = (() => {
if (typeof tpl.default_duration_minutes === 'number') return tpl.default_duration_minutes;
if (typeof tpl.default_duration === 'number') return Math.round(tpl.default_duration * 60);
return 0;
})();
if (minutes > 0) {
const sd = document.getElementById('start_date');
const st = document.getElementById('start_time');
const ed = document.getElementById('end_date');
const et = document.getElementById('end_time');
if (sd && st && ed && et && sd.value && st.value) {
const start = new Date(`${sd.value}T${st.value}:00`);
if (!isNaN(start.getTime())) {
const end = new Date(start.getTime() + minutes * 60000);
const yyyy = end.getFullYear();
const mm = String(end.getMonth() + 1).padStart(2,'0');
const dd = String(end.getDate()).padStart(2,'0');
const hh = String(end.getHours()).padStart(2,'0');
const mi = String(end.getMinutes()).padStart(2,'0');
ed.value = `${yyyy}-${mm}-${dd}`;
et.value = `${hh}:${mi}`;
}
}
}
// Update worked time field after applying duration, if present
try { updateWorkedFromStartEnd(); } catch (_) {}
// Clear after applying so it does not persist
try { sessionStorage.removeItem('activeTemplate'); } catch(_) {}
}
} catch(_) {}
}
});
</script>
<!-- Toast UI Editor: load only on desktop to avoid mobile freeze (Issue #557) -->
<script>
document.addEventListener('DOMContentLoaded', function() {
if (window.isMobileView) {
window.mdEditor = null;
return;
}
var link1 = document.createElement('link');
link1.rel = 'stylesheet';
link1.href = 'https://uicdn.toast.com/editor/latest/toastui-editor.min.css';
document.head.appendChild(link1);
var link2 = document.createElement('link');
link2.rel = 'stylesheet';
link2.href = 'https://uicdn.toast.com/editor/latest/theme/toastui-editor-dark.css';
document.head.appendChild(link2);
var scriptEl = document.createElement('script');
scriptEl.src = 'https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js';
scriptEl.onload = function initToastEditorManualEntry() {
const notesInput = document.getElementById('notes');
let mdEditor = null;
if (notesInput && window.toastui && window.toastui.Editor) {
const theme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
mdEditor = new toastui.Editor({
el: document.getElementById('notes_editor'),
height: '300px',
initialEditType: 'wysiwyg',
previewStyle: 'vertical',
usageStatistics: false,
hideModeSwitch: false,
placeholder: {{ _("What did you work on?")|tojson }},
theme: theme,
toolbarItems: [
['heading', 'bold', 'italic', 'strike'],
['hr', 'quote'],
['ul', 'ol', 'task'],
['link', 'code', 'codeblock', 'table'],
['image'],
['scrollSync']
],
initialValue: notesInput.value || ''
});
// Apply theme changes dynamically if supported
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === 'attributes' && mutation.attributeName === 'class' && mdEditor) {
const nextTheme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
try {
if (typeof mdEditor.setTheme === 'function') {
mdEditor.setTheme(nextTheme);
}
} catch (e) {}
}
});
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
// Multiple image upload handler - improved version
mdEditor.removeHook && mdEditor.removeHook('addImageBlobHook');
// Create custom multiple file input with unique ID
const fileInputId = 'manual-entry-image-input-' + Date.now();
let fileInput = document.getElementById(fileInputId);
if (!fileInput) {
fileInput = document.createElement('input');
fileInput.id = fileInputId;
fileInput.type = 'file';
fileInput.accept = 'image/*';
fileInput.setAttribute('multiple', 'multiple'); // Use setAttribute to ensure it's set
fileInput.style.display = 'none';
fileInput.style.position = 'absolute';
fileInput.style.left = '-9999px';
document.body.appendChild(fileInput);
}
// Verify multiple attribute is set
if (!fileInput.hasAttribute('multiple')) {
fileInput.setAttribute('multiple', 'multiple');
}
console.log('File input multiple attribute:', fileInput.multiple, fileInput.hasAttribute('multiple'));
// Function to find image button with multiple strategies
function findImageButton(toolbar, retries = 0) {
if (!toolbar) return null;
// Try multiple selectors in order of reliability
const selectors = [
'[data-tooltip="Insert image"]',
'[data-tooltip*="image" i]',
'[aria-label*="image" i]',
'.toastui-editor-toolbar-icons.image',
'button[class*="image"]',
'.toastui-editor-toolbar-group button:nth-child(5)', // Common position for image button
'.toastui-editor-toolbar-group button:nth-child(6)',
'.toastui-editor-toolbar-group button:nth-child(4)'
];
for (const selector of selectors) {
try {
const button = toolbar.querySelector(selector);
if (button) {
// Verify it's likely the image button by checking if it has image-related attributes or classes
const buttonText = (button.textContent || '').toLowerCase();
const buttonTitle = (button.title || button.getAttribute('title') || '').toLowerCase();
if (buttonText.includes('image') || buttonTitle.includes('image') ||
selector.includes('image') || button.classList.toString().includes('image')) {
return button;
}
}
} catch (e) {
// Continue to next selector
}
}
return null;
}
// Function to intercept image button with retry logic
function setupImageButtonInterception(editor, input, maxRetries = 5) {
let attempts = 0;
let intercepted = false;
function tryIntercept() {
attempts++;
try {
// Get the editor's root element - try multiple ways
const editorContainer = document.getElementById('notes_editor');
if (!editorContainer) {
throw new Error('Editor container element not found');
}
// Find toolbar - try multiple ways to access it
let toolbar = null;
// Try finding toolbar relative to editor container
let editorWrapper = null;
if (editorContainer.closest) {
editorWrapper = editorContainer.closest('.toastui-editor');
}
if (!editorWrapper && editorContainer.parentElement) {
editorWrapper = editorContainer.parentElement;
}
if (editorWrapper) {
toolbar = editorWrapper.querySelector('.toastui-editor-toolbar');
}
// Fallback: search entire document
if (!toolbar) {
toolbar = document.querySelector('.toastui-editor-toolbar');
}
if (toolbar && !intercepted) {
const imageButton = findImageButton(toolbar, attempts);
if (imageButton) {
// Use capture phase to intercept before ToastUI's handler
imageButton.addEventListener('click', function handler(e) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
// Small delay to ensure default behavior is prevented
setTimeout(() => {
input.click();
}, 10);
return false;
}, true); // Use capture phase
intercepted = true;
console.log('Successfully intercepted image button for multiple file selection');
return true;
}
}
} catch (err) {
console.warn('Error intercepting image button (attempt ' + attempts + '):', err);
}
// Retry if we haven't exceeded max retries
if (attempts < maxRetries && !intercepted) {
setTimeout(tryIntercept, 200 * attempts); // Exponential backoff
return false;
} else if (!intercepted) {
console.warn('Could not intercept image button after ' + maxRetries + ' attempts. Multiple image selection may not work via toolbar button.');
return false;
}
return false;
}
// Start with initial delay to ensure toolbar is ready
setTimeout(tryIntercept, 100);
}
// Override the addImageBlobHook to use multiple file selection
// This is the fallback if button interception fails
if (mdEditor && typeof mdEditor.addHook === 'function') {
mdEditor.addHook('addImageBlobHook', async function(blob, callback) {
// Prevent the default single file behavior
// Trigger our multiple file input instead
setTimeout(function() {
fileInput.click();
}, 10);
// Don't call callback - let fileInput.change handle it
});
}
// Setup button interception
if (mdEditor) {
setupImageButtonInterception(mdEditor, fileInput);
}
// Handle multiple file selection with progress feedback
fileInput.addEventListener('change', async (e) => {
const files = Array.from(e.target.files);
console.log('File input changed. Files selected:', files.length, 'Multiple attribute:', fileInput.multiple);
if (files.length === 0) {
console.log('No files selected');
return;
}
// Log file details for debugging
files.forEach((file, index) => {
console.log(`File ${index + 1}: ${file.name}, type: ${file.type}, size: ${file.size}`);
});
// Validate file types
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp'];
const validFiles = files.filter(file => {
const isValid = allowedTypes.includes(file.type) || /\.(png|jpg|jpeg|gif|webp)$/i.test(file.name);
if (!isValid) {
console.warn('Skipping invalid file type:', file.name, file.type);
}
return isValid;
});
if (validFiles.length === 0) {
alert({{ _("No valid image files selected. Please select PNG, JPG, GIF, or WebP images.")|tojson }});
fileInput.value = '';
return;
}
if (validFiles.length < files.length) {
console.warn('Some files were skipped due to invalid type');
}
// Show loading feedback
const fileCount = validFiles.length;
const loadingMsg = fileCount > 1 ?
({{ _("Uploading")|tojson }} + ' ' + fileCount + ' ' + {{ _("images")|tojson }} + '...') :
{{ _("Uploading image")|tojson }} + '...';
// Try to show a temporary loading indicator (if toast/notification system exists)
let loadingIndicator = null;
try {
// Check if there's a toast/notification system we can use
if (window.showToast || window.showNotification) {
const showToast = window.showToast || window.showNotification;
loadingIndicator = showToast(loadingMsg, 'info', { duration: 0 });
}
} catch (e) {
// No toast system available, continue without it
}
try {
const formData = new FormData();
validFiles.forEach(file => {
formData.append('images', file);
});
const res = await fetch('{{ url_for('api.upload_editor_images_bulk') }}', {
method: 'POST',
body: formData
});
const data = await res.json();
if (data && data.urls && data.urls.length > 0) {
// Insert all images into editor
const imagesMarkdown = data.urls.map((url, index) => {
const fileName = validFiles[index].name || 'image';
return `![${fileName}](${url})`;
}).join('\n\n');
// Get current markdown content
const currentMarkdown = mdEditor.getMarkdown() || '';
// Append images to current content (with proper spacing)
const newMarkdown = currentMarkdown
? currentMarkdown + '\n\n' + imagesMarkdown
: imagesMarkdown;
// Set the markdown which will properly render images in WYSIWYG mode
mdEditor.setMarkdown(newMarkdown);
// Show success message
const successMsg = data.urls.length > 1 ?
({{ _("Successfully uploaded")|tojson }} + ' ' + data.urls.length + ' ' + {{ _("images")|tojson }}) :
{{ _("Image uploaded successfully")|tojson }};
if (loadingIndicator && typeof loadingIndicator.update === 'function') {
loadingIndicator.update(successMsg, 'success');
setTimeout(() => loadingIndicator.remove && loadingIndicator.remove(), 2000);
}
// Show warnings if any
if (data.warnings && data.warnings.length > 0) {
console.warn('Some images failed to upload:', data.warnings);
const warningMsg = {{ _("Warning")|tojson }} + ': ' + data.warnings.length + ' ' + {{ _("image(s) failed to upload")|tojson }};
if (window.showToast || window.showNotification) {
const showToast = window.showToast || window.showNotification;
showToast(warningMsg, 'warning');
} else {
alert(warningMsg);
}
}
} else {
const errorMsg = data.error || {{ _("Failed to upload images. Please try again.")|tojson }};
console.error('Upload failed', data);
if (loadingIndicator && typeof loadingIndicator.remove === 'function') {
loadingIndicator.remove();
}
alert(errorMsg);
}
} catch (error) {
console.error('Multiple image upload error', error);
if (loadingIndicator && typeof loadingIndicator.remove === 'function') {
loadingIndicator.remove();
}
alert({{ _("Failed to upload images. Please check your connection and try again.")|tojson }});
}
// Reset file input
fileInput.value = '';
});
// Make editor accessible globally for template application
window.mdEditor = mdEditor;
// Sync editor content to hidden textarea on form submit
const form = document.getElementById('manualEntryForm');
if (form) {
form.addEventListener('submit', function(e) {
if (mdEditor && notesInput) {
try {
notesInput.value = mdEditor.getMarkdown();
} catch (err) {
console.error('Failed to sync markdown editor:', err);
}
}
});
}
// Update notes when template is applied
const originalNotesHandler = window.addEventListener;
// This will be handled in the existing template application code
}
}; // end Toast UI init onload
document.head.appendChild(scriptEl);
});
</script>
{% endblock %}