mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2025-12-31 16:30:00 -06:00
- Analytics: refine desktop and mobile dashboards - Projects: update create/edit/list/view templates and route logic - Timer: refresh calendar and timer templates; adjust command handling - Layout: update base.html; unify styles in base.css and mobile.css - Comments: enhance comments section and edit view - Errors: improve 404 and 500 pages - JS: tweak commands.js and mobile.js for responsiveness - Add app/static/manifest.webmanifest for PWA support - Accessibility, responsiveness, and UI consistency improvements
877 lines
40 KiB
HTML
877 lines
40 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}{{ _('Timer') }} - {{ app_name }}{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container-fluid">
|
|
<!-- Header Section -->
|
|
<div class="row mb-4">
|
|
<div class="col-12">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<h1 class="h2 mb-1">
|
|
<i class="fas fa-clock text-primary me-2"></i>{{ _('Timer') }}
|
|
</h1>
|
|
<p class="text-muted mb-0">{{ _('Track your time with precision') }}</p>
|
|
</div>
|
|
<div>
|
|
<button type="button" id="openStartTimerBtn" class="btn-header btn-primary" data-bs-toggle="modal" data-bs-target="#startTimerModal">
|
|
<i class="fas fa-play"></i>{{ _('Start Timer') }}
|
|
</button>
|
|
<button type="button" id="openFocusBtn" class="btn-header btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#focusModal">
|
|
<i class="fas fa-hourglass-start"></i>{{ _('Focus Mode') }}
|
|
</button>
|
|
<button type="button" id="resumeTimerBtn" class="btn-header btn-outline-primary d-none">
|
|
<i class="fas fa-redo"></i>{{ _('Resume Last') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Active Timer Section -->
|
|
<div class="row mb-4" id="activeTimerSection" style="display: none;">
|
|
<div class="col-12">
|
|
<div class="card border-success">
|
|
<div class="card-header bg-success text-white">
|
|
<h5 class="mb-0">
|
|
<i class="fas fa-play-circle me-2"></i>{{ _('Active Timer') }}
|
|
</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="row align-items-center">
|
|
<div class="col-md-6">
|
|
<div class="d-flex align-items-center mb-3">
|
|
<div class="me-3">
|
|
<div class="bg-success bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center" style="width: 60px; height: 60px;">
|
|
<i class="fas fa-play text-success fa-2x"></i>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<h5 id="activeProjectName" class="text-success mb-1"></h5>
|
|
<p id="activeTimerNotes" class="text-muted mb-0"></p>
|
|
</div>
|
|
</div>
|
|
<div class="d-flex align-items-center">
|
|
<i class="fas fa-clock text-muted me-2"></i>
|
|
<small class="text-muted">
|
|
{{ _('Started:') }} <span id="activeTimerStart" class="fw-semibold"></span>
|
|
</small>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3 text-center">
|
|
<div class="timer-display mb-2" id="activeTimerDisplay">00:00:00</div>
|
|
<small class="text-muted fw-semibold">{{ _('Duration') }}</small>
|
|
</div>
|
|
<div class="col-md-3 text-center text-md-end">
|
|
<button type="button" class="btn btn-danger px-4" id="stopTimerBtn">
|
|
<i class="fas fa-stop me-2"></i>{{ _('Stop Timer') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- No Active Timer -->
|
|
<div class="row mb-4" id="noActiveTimerSection">
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-body text-center py-5">
|
|
<div class="mb-4">
|
|
<i class="fas fa-clock fa-3x text-muted opacity-50"></i>
|
|
</div>
|
|
<h3 class="text-muted mb-3">{{ _('No Active Timer') }}</h3>
|
|
<p class="text-muted mb-4">{{ _('Start a timer to begin tracking your time effectively.') }}</p>
|
|
<button type="button" id="openStartTimerBtnEmpty" class="btn btn-primary px-4" data-bs-toggle="modal" data-bs-target="#startTimerModal">
|
|
<i class="fas fa-play me-2"></i>{{ _('Start Timer') }}
|
|
</button>
|
|
<button type="button" id="resumeTimerBtnEmpty" class="btn btn-outline-primary px-4 ms-2">
|
|
<i class="fas fa-redo me-2"></i>{{ _('Resume Last') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Recent Entries -->
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h5 class="mb-0">
|
|
<i class="fas fa-history me-2 text-primary"></i>{{ _('Recent Time Entries') }}
|
|
</h5>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
<div id="recentEntriesList">
|
|
<!-- Entries will be loaded here -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Start Timer Modal -->
|
|
<div class="modal fade" id="startTimerModal" tabindex="-1">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">
|
|
<i class="fas fa-play me-2 text-success"></i>{{ _('Start Timer') }}
|
|
</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<form id="startTimerForm">
|
|
<div class="modal-body">
|
|
<div class="mb-4">
|
|
<label for="projectSelect" class="form-label fw-semibold">
|
|
<i class="fas fa-project-diagram me-1"></i>{{ _('Project') }} *
|
|
</label>
|
|
<select class="form-select form-select-lg" id="projectSelect" name="project_id" required>
|
|
<option value="">{{ _('Select a project...') }}</option>
|
|
</select>
|
|
</div>
|
|
<div class="mb-4">
|
|
<label for="timerNotes" class="form-label fw-semibold">
|
|
<i class="fas fa-sticky-note me-1"></i>{{ _('Notes') }}
|
|
</label>
|
|
<textarea class="form-control" id="timerNotes" name="notes" rows="3"
|
|
placeholder="{{ _('What are you working on?') }}"></textarea>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label for="timerTags" class="form-label fw-semibold">
|
|
<i class="fas fa-tags me-1"></i>{{ _('Tags') }}
|
|
</label>
|
|
<input type="text" class="form-control" id="timerTags" name="tags"
|
|
placeholder="{{ _('tag1, tag2, tag3') }}">
|
|
<div class="form-text">{{ _('Separate tags with commas') }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
|
<i class="fas fa-times me-1"></i>{{ _('Cancel') }}
|
|
</button>
|
|
<button type="submit" class="btn btn-success">
|
|
<i class="fas fa-play me-2"></i>{{ _('Start Timer') }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- Focus Mode Modal -->
|
|
<div class="modal fade" id="focusModal" tabindex="-1">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title"><i class="fas fa-hourglass-half me-2 text-primary"></i>{{ _('Focus Mode') }}</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="row g-3">
|
|
<div class="col-6">
|
|
<label class="form-label">{{ _('Pomodoro (min)') }}</label>
|
|
<input type="number" class="form-control" id="pomodoroLen" value="25" min="1">
|
|
</div>
|
|
<div class="col-6">
|
|
<label class="form-label">{{ _('Short Break (min)') }}</label>
|
|
<input type="number" class="form-control" id="shortBreakLen" value="5" min="1">
|
|
</div>
|
|
<div class="col-6">
|
|
<label class="form-label">{{ _('Long Break (min)') }}</label>
|
|
<input type="number" class="form-control" id="longBreakLen" value="15" min="1">
|
|
</div>
|
|
<div class="col-6">
|
|
<label class="form-label">{{ _('Long Break Every') }}</label>
|
|
<input type="number" class="form-control" id="longBreakEvery" value="4" min="1">
|
|
</div>
|
|
</div>
|
|
<div class="form-check mt-3">
|
|
<input class="form-check-input" type="checkbox" id="linkActiveTimer" checked>
|
|
<label class="form-check-label" for="linkActiveTimer">{{ _('Link to active timer if running') }}</label>
|
|
</div>
|
|
<div class="mt-3 small text-muted" id="focusSummary"></div>
|
|
<div class="mt-3">
|
|
<div class="d-flex align-items-center">
|
|
<i class="fas fa-history text-primary me-2"></i>
|
|
<strong>{{ _('Recent Focus Sessions (7 days)') }}</strong>
|
|
</div>
|
|
<div id="focusHistory" class="small text-muted"></div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-secondary" data-bs-dismiss="modal">{{ _('Close') }}</button>
|
|
<button class="btn btn-primary" id="startFocusSessionBtn"><i class="fas fa-play me-2"></i>{{ _('Start Focus') }}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Edit Timer Modal -->
|
|
<div class="modal fade" id="editTimerModal" tabindex="-1">
|
|
<div class="modal-dialog modal-dialog-centered modal-lg">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">
|
|
<i class="fas fa-edit me-2 text-primary"></i>{{ _('Edit Time Entry') }}
|
|
</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<form id="editTimerForm" novalidate>
|
|
<div class="modal-body">
|
|
<input type="hidden" id="editEntryId" name="entry_id">
|
|
<div class="row">
|
|
<div class="col-md-6">
|
|
<div class="mb-4">
|
|
<label for="editProjectSelect" class="form-label fw-semibold">
|
|
<i class="fas fa-project-diagram me-1"></i>{{ _('Project') }} *
|
|
</label>
|
|
<select class="form-select" id="editProjectSelect" name="project_id" required>
|
|
<option value="">{{ _('Select a project...') }}</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<div class="mb-4">
|
|
<label class="form-label fw-semibold">
|
|
<i class="fas fa-clock me-1"></i>{{ _('Duration') }}
|
|
</label>
|
|
<div class="form-control-plaintext" id="editDurationDisplay">--:--:--</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="row">
|
|
<div class="col-md-6">
|
|
<div class="mb-4">
|
|
<label for="editStartTime" class="form-label fw-semibold">
|
|
<i class="fas fa-play me-1"></i>{{ _('Start Time') }} *
|
|
</label>
|
|
<input type="datetime-local" class="form-control" id="editStartTime" name="start_time" required>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<div class="mb-4">
|
|
<label for="editEndTime" class="form-label fw-semibold">
|
|
<i class="fas fa-stop me-1"></i>{{ _('End Time') }} *
|
|
</label>
|
|
<input type="datetime-local" class="form-control" id="editEndTime" name="end_time" required>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="mb-4">
|
|
<label for="editNotes" class="form-label fw-semibold">
|
|
<i class="fas fa-sticky-note me-1"></i>{{ _('Notes') }}
|
|
</label>
|
|
<textarea class="form-control" id="editNotes" name="notes" rows="3"></textarea>
|
|
</div>
|
|
<div class="row">
|
|
<div class="col-md-8">
|
|
<div class="mb-4">
|
|
<label for="editTags" class="form-label fw-semibold">
|
|
<i class="fas fa-tags me-1"></i>{{ _('Tags') }}
|
|
</label>
|
|
<input type="text" class="form-control" id="editTags" name="tags"
|
|
placeholder="{{ _('tag1, tag2, tag3') }}">
|
|
<div class="form-text">{{ _('Separate tags with commas') }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<div class="mb-4">
|
|
<div class="form-check form-switch mt-4">
|
|
<input class="form-check-input" type="checkbox" id="editBillable" name="billable">
|
|
<label class="form-check-label fw-semibold" for="editBillable">
|
|
<i class="fas fa-dollar-sign me-1"></i>{{ _('Billable') }}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-danger me-auto" id="deleteTimerBtn">
|
|
<i class="fas fa-trash me-1"></i>{{ _('Delete') }}
|
|
</button>
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
|
<i class="fas fa-times me-1"></i>{{ _('Cancel') }}
|
|
</button>
|
|
<button type="submit" class="btn btn-primary" id="editSaveBtn">
|
|
<i class="fas fa-save me-2"></i>{{ _('Save Changes') }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Delete Entry Confirmation Modal -->
|
|
<div class="modal fade" id="deleteEntryModal" tabindex="-1">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">
|
|
<i class="fas fa-trash me-2 text-danger"></i>{{ _('Delete Time Entry') }}
|
|
</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ _('Close') }}"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="alert alert-warning">
|
|
<i class="fas fa-exclamation-triangle me-2"></i>
|
|
<strong>{{ _('Warning:') }}</strong> {{ _('This action cannot be undone.') }}
|
|
</div>
|
|
<p>{{ _('Are you sure you want to delete this time entry?') }}</p>
|
|
<p class="text-muted mb-0">{{ _('This will permanently remove the entry and cannot be recovered.') }}</p>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
|
<i class="fas fa-times me-1"></i>{{ _('Cancel') }}
|
|
</button>
|
|
<button type="button" class="btn btn-danger" id="confirmDeleteBtn">
|
|
<i class="fas fa-trash me-2"></i>{{ _('Delete Entry') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script>
|
|
let activeTimer = null;
|
|
let timerInterval = null;
|
|
|
|
// Load projects for dropdowns
|
|
function loadProjects() {
|
|
const promise = fetch('/api/projects', { credentials: 'same-origin' })
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
const projectSelect = document.getElementById('projectSelect');
|
|
const editProjectSelect = document.getElementById('editProjectSelect');
|
|
|
|
if (!projectSelect || !editProjectSelect) return;
|
|
|
|
// Clear existing options
|
|
projectSelect.innerHTML = '<option value="">{{ _('Select a project...') }}</option>';
|
|
editProjectSelect.innerHTML = '<option value="">{{ _('Select a project...') }}</option>';
|
|
|
|
data.projects.forEach(project => {
|
|
if (project.status === 'active') {
|
|
const option = new Option(project.name, project.id);
|
|
projectSelect.add(option);
|
|
|
|
const editOption = new Option(project.name, project.id);
|
|
editProjectSelect.add(editOption);
|
|
}
|
|
});
|
|
return data.projects;
|
|
})
|
|
.catch(error => {
|
|
console.error('Error loading projects:', error);
|
|
showToast('{{ _('Error loading projects') }}', 'error');
|
|
});
|
|
window.projectsLoadedPromise = promise;
|
|
return promise;
|
|
}
|
|
|
|
// Check timer status
|
|
function checkTimerStatus() {
|
|
fetch('/api/timer/status', { credentials: 'same-origin' })
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.active && data.timer) {
|
|
activeTimer = data.timer;
|
|
showActiveTimer();
|
|
startTimerDisplay();
|
|
} else {
|
|
hideActiveTimer();
|
|
// show resume buttons
|
|
document.getElementById('resumeTimerBtn').classList.remove('d-none');
|
|
document.getElementById('resumeTimerBtnEmpty').classList.remove('d-none');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error checking timer status:', error);
|
|
});
|
|
}
|
|
|
|
// Show active timer
|
|
function showActiveTimer() {
|
|
document.getElementById('noActiveTimerSection').style.display = 'none';
|
|
document.getElementById('activeTimerSection').style.display = 'block';
|
|
const openBtns = [document.getElementById('openStartTimerBtn'), document.getElementById('openStartTimerBtnEmpty')];
|
|
openBtns.forEach(btn => { if (btn) { btn.disabled = true; btn.classList.add('disabled'); btn.setAttribute('title', '{{ _('Stop the current timer first') }}'); }});
|
|
|
|
document.getElementById('activeProjectName').textContent = activeTimer.project_name;
|
|
document.getElementById('activeTimerNotes').textContent = activeTimer.notes || '{{ _('No notes') }}';
|
|
document.getElementById('activeTimerStart').textContent = new Date(activeTimer.start_time).toLocaleString();
|
|
}
|
|
|
|
// Hide active timer
|
|
function hideActiveTimer() {
|
|
document.getElementById('noActiveTimerSection').style.display = 'block';
|
|
document.getElementById('activeTimerSection').style.display = 'none';
|
|
activeTimer = null;
|
|
|
|
// Re-enable start buttons
|
|
const openBtns = [document.getElementById('openStartTimerBtn'), document.getElementById('openStartTimerBtnEmpty')];
|
|
openBtns.forEach(btn => { if (btn) { btn.disabled = false; btn.classList.remove('disabled'); btn.removeAttribute('title'); }});
|
|
|
|
if (timerInterval) {
|
|
clearInterval(timerInterval);
|
|
timerInterval = null;
|
|
}
|
|
}
|
|
|
|
// Start timer display
|
|
function startTimerDisplay() {
|
|
if (timerInterval) {
|
|
clearInterval(timerInterval);
|
|
}
|
|
|
|
timerInterval = setInterval(() => {
|
|
if (activeTimer) {
|
|
const now = new Date();
|
|
const start = new Date(activeTimer.start_time);
|
|
const duration = Math.floor((now - start) / 1000);
|
|
|
|
const hours = Math.floor(duration / 3600);
|
|
const minutes = Math.floor((duration % 3600) / 60);
|
|
const seconds = duration % 60;
|
|
|
|
document.getElementById('activeTimerDisplay').textContent =
|
|
`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
// Load recent entries
|
|
function loadRecentEntries() {
|
|
fetch('/api/entries?limit=10', { credentials: 'same-origin' })
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
const container = document.getElementById('recentEntriesList');
|
|
|
|
if (data.entries.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="text-center py-5">
|
|
<div class="mb-4">
|
|
<i class="fas fa-clock fa-4x text-muted opacity-50"></i>
|
|
</div>
|
|
<h5 class="text-muted mb-3">{{ _('No time entries yet') }}</h5>
|
|
<p class="text-muted mb-4">{{ _('Start tracking your time to see entries here') }}</p>
|
|
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#startTimerModal">
|
|
<i class="fas fa-play me-2"></i>{{ _('Start Your First Timer') }}
|
|
</button>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = data.entries.map(entry => `
|
|
<div class="d-flex justify-content-between align-items-center py-3 px-4 border-bottom">
|
|
<div class="d-flex align-items-center">
|
|
<div class="me-3">
|
|
<div class="bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center" style="width: 50px; height: 50px;">
|
|
<i class="fas fa-project-diagram text-primary"></i>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<strong class="d-block">${entry.project_name}</strong>
|
|
${entry.notes ? `<small class="text-muted d-block">${entry.notes}</small>` : ''}
|
|
<small class="text-muted">
|
|
<i class="fas fa-calendar me-1"></i>
|
|
${new Date(entry.start_time).toLocaleDateString()}
|
|
<i class="fas fa-clock ms-2 me-1"></i>
|
|
${new Date(entry.start_time).toLocaleTimeString()} -
|
|
${entry.end_time ? new Date(entry.end_time).toLocaleTimeString() : '{{ _('Running') }}'}
|
|
</small>
|
|
</div>
|
|
</div>
|
|
<div class="text-end">
|
|
<div class="badge bg-primary fs-6 mb-2">${entry.duration_formatted}</div>
|
|
<br>
|
|
<div class="btn-group" role="group">
|
|
<button class="btn btn-sm btn-action btn-action--edit" onclick="editEntry(${entry.id})" title="{{ _('Edit entry') }}">
|
|
<i class="fas fa-edit"></i>
|
|
</button>
|
|
<button class="btn btn-sm btn-action btn-action--danger" onclick="deleteEntry(${entry.id})" title="{{ _('Delete entry') }}">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
})
|
|
.catch(error => {
|
|
console.error('Error loading recent entries:', error);
|
|
});
|
|
}
|
|
|
|
// Start timer
|
|
document.getElementById('startTimerForm').addEventListener('submit', function(e) {
|
|
e.preventDefault();
|
|
|
|
const formData = new FormData(this);
|
|
const data = {
|
|
project_id: formData.get('project_id'),
|
|
notes: formData.get('notes'),
|
|
tags: formData.get('tags')
|
|
};
|
|
|
|
// Show loading state
|
|
const submitBtn = this.querySelector('button[type="submit"]');
|
|
submitBtn.innerHTML = '<div class="loading-spinner me-2"></div>{{ _('Starting...') }}';
|
|
submitBtn.disabled = true;
|
|
|
|
fetch('/api/timer/start', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify(data)
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
showToast('{{ _('Timer started successfully') }}', 'success');
|
|
bootstrap.Modal.getInstance(document.getElementById('startTimerModal')).hide();
|
|
this.reset();
|
|
checkTimerStatus();
|
|
loadRecentEntries();
|
|
} else {
|
|
showToast(data.error || data.message || '{{ _('Error starting timer') }}', 'error');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error starting timer:', error);
|
|
showToast('{{ _('Error starting timer') }}', 'error');
|
|
})
|
|
.finally(() => {
|
|
submitBtn.innerHTML = '<i class="fas fa-play me-2"></i>{{ _('Start Timer') }}';
|
|
submitBtn.disabled = false;
|
|
});
|
|
});
|
|
|
|
// Stop timer
|
|
document.getElementById('stopTimerBtn').addEventListener('click', function() {
|
|
this.innerHTML = '<div class="loading-spinner me-2"></div>{{ _('Stopping...') }}';
|
|
this.disabled = true;
|
|
|
|
fetch('/api/timer/stop', {
|
|
method: 'POST',
|
|
credentials: 'same-origin'
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
showToast('{{ _('Timer stopped successfully') }}', 'success');
|
|
hideActiveTimer();
|
|
loadRecentEntries();
|
|
} else {
|
|
showToast(data.message || '{{ _('Error stopping timer') }}', 'error');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error stopping timer:', error);
|
|
showToast('{{ _('Error stopping timer') }}', 'error');
|
|
})
|
|
.finally(() => {
|
|
this.innerHTML = '<i class="fas fa-stop me-2"></i>{{ _('Stop Timer') }}';
|
|
this.disabled = false;
|
|
});
|
|
});
|
|
|
|
// Edit entry
|
|
function editEntry(entryId) {
|
|
const ensureProjects = window.projectsLoadedPromise || loadProjects();
|
|
Promise.resolve(ensureProjects).then(() => {
|
|
return fetch(`/api/entry/${entryId}`, { credentials: 'same-origin' })
|
|
.then(response => response.json());
|
|
})
|
|
.then(data => {
|
|
document.getElementById('editEntryId').value = entryId;
|
|
const editProjectSelect = document.getElementById('editProjectSelect');
|
|
// Ensure the project option exists; if not, append it
|
|
if (!Array.from(editProjectSelect.options).some(o => o.value == data.project_id)) {
|
|
const opt = new Option(data.project_name || `{{ _('Project') }} ${data.project_id}`, data.project_id);
|
|
editProjectSelect.add(opt);
|
|
}
|
|
editProjectSelect.value = data.project_id;
|
|
// Convert ISO to local datetime-local value
|
|
const startIso = data.start_time;
|
|
const endIso = data.end_time;
|
|
const toLocalInput = (iso) => {
|
|
if (!iso) return '';
|
|
const d = new Date(iso);
|
|
const pad = (n) => n.toString().padStart(2, '0');
|
|
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
};
|
|
document.getElementById('editStartTime').value = toLocalInput(startIso);
|
|
document.getElementById('editEndTime').value = toLocalInput(endIso);
|
|
document.getElementById('editNotes').value = data.notes || '';
|
|
document.getElementById('editTags').value = data.tags || '';
|
|
document.getElementById('editBillable').checked = data.billable;
|
|
|
|
// Calculate and display duration
|
|
updateEditDurationDisplay();
|
|
|
|
new bootstrap.Modal(document.getElementById('editTimerModal')).show();
|
|
})
|
|
.catch(error => {
|
|
console.error('Error loading entry:', error);
|
|
showToast('{{ _('Error loading entry') }}', 'error');
|
|
});
|
|
}
|
|
|
|
// Debug: signal that edit form handler is attached
|
|
console.debug('Edit form submit handler attached');
|
|
|
|
function performEditSave() {
|
|
const form = document.getElementById('editTimerForm');
|
|
if (!form) return;
|
|
const entryId = document.getElementById('editEntryId').value;
|
|
const formData = new FormData(form);
|
|
const data = {
|
|
project_id: formData.get('project_id'),
|
|
start_time: formData.get('start_time'),
|
|
end_time: formData.get('end_time'),
|
|
notes: formData.get('notes'),
|
|
tags: formData.get('tags'),
|
|
billable: formData.get('billable') === 'on'
|
|
};
|
|
console.debug('Submitting data', data);
|
|
|
|
// Validate
|
|
const startVal = data.start_time ? new Date(data.start_time) : null;
|
|
const endVal = data.end_time ? new Date(data.end_time) : null;
|
|
if (startVal && endVal && endVal <= startVal) {
|
|
showToast('{{ _('End time must be after start time') }}', 'error');
|
|
return;
|
|
}
|
|
|
|
const submitBtn = form.querySelector('button[type="submit"]');
|
|
if (submitBtn) {
|
|
submitBtn.innerHTML = '<div class="loading-spinner me-2"></div>{{ _('Saving...') }}';
|
|
submitBtn.disabled = true;
|
|
}
|
|
|
|
fetch(`/api/entry/${entryId}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify(data)
|
|
})
|
|
.then(r => r.json())
|
|
.then(payload => {
|
|
console.debug('PUT response', payload);
|
|
if (payload.success) {
|
|
showToast('{{ _('Entry updated successfully') }}', 'success');
|
|
const modal = bootstrap.Modal.getInstance(document.getElementById('editTimerModal'));
|
|
if (modal) modal.hide();
|
|
loadRecentEntries();
|
|
} else {
|
|
showToast(payload.message || '{{ _('Error updating entry') }}', 'error');
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error('Error updating entry:', err);
|
|
showToast('{{ _('Error updating entry') }}', 'error');
|
|
})
|
|
.finally(() => {
|
|
if (submitBtn) {
|
|
submitBtn.innerHTML = '<i class="fas fa-save me-2"></i>{{ _('Save Changes') }}';
|
|
submitBtn.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Edit timer form
|
|
document.getElementById('editTimerForm').addEventListener('submit', function(e) {
|
|
e.preventDefault();
|
|
console.debug('Edit form submit event fired');
|
|
performEditSave();
|
|
});
|
|
|
|
// Ensure click on Save triggers submit handler even if native validation blocks it
|
|
document.addEventListener('click', function(e) {
|
|
const btn = e.target.closest('#editSaveBtn');
|
|
if (!btn) return;
|
|
e.preventDefault();
|
|
performEditSave();
|
|
});
|
|
|
|
// Live update duration when times change in edit modal
|
|
function updateEditDurationDisplay() {
|
|
const start = document.getElementById('editStartTime').value;
|
|
const end = document.getElementById('editEndTime').value;
|
|
const display = document.getElementById('editDurationDisplay');
|
|
if (!start || !end) {
|
|
display.textContent = '--:--:--';
|
|
return;
|
|
}
|
|
const startDate = new Date(start);
|
|
const endDate = new Date(end);
|
|
const diff = Math.max(0, Math.floor((endDate - startDate) / 1000));
|
|
const h = Math.floor(diff / 3600);
|
|
const m = Math.floor((diff % 3600) / 60);
|
|
const s = diff % 60;
|
|
display.textContent = `${h.toString().padStart(2,'0')}:${m.toString().padStart(2,'0')}:${s.toString().padStart(2,'0')}`;
|
|
}
|
|
document.getElementById('editStartTime').addEventListener('change', updateEditDurationDisplay);
|
|
document.getElementById('editEndTime').addEventListener('change', updateEditDurationDisplay);
|
|
|
|
// Delete timer
|
|
document.getElementById('deleteTimerBtn').addEventListener('click', function() {
|
|
const entryId = document.getElementById('editEntryId').value;
|
|
|
|
// Store the entry ID and button reference for the modal
|
|
window.pendingDeleteEntryId = entryId;
|
|
window.pendingDeleteButton = this;
|
|
|
|
// Show the delete confirmation modal
|
|
new bootstrap.Modal(document.getElementById('deleteEntryModal')).show();
|
|
});
|
|
|
|
// Delete entry directly
|
|
function deleteEntry(entryId) {
|
|
// Store the entry ID for the modal
|
|
window.pendingDeleteEntryId = entryId;
|
|
window.pendingDeleteButton = null;
|
|
|
|
// Show the delete confirmation modal
|
|
new bootstrap.Modal(document.getElementById('deleteEntryModal')).show();
|
|
}
|
|
|
|
// Initialize
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
window.projectsLoadedPromise = loadProjects();
|
|
checkTimerStatus();
|
|
loadRecentEntries();
|
|
// Resume timer actions
|
|
const resumeButtons = [document.getElementById('resumeTimerBtn'), document.getElementById('resumeTimerBtnEmpty')];
|
|
resumeButtons.forEach(btn => {
|
|
if (!btn) return;
|
|
btn.addEventListener('click', async function(){
|
|
try {
|
|
btn.disabled = true; btn.innerHTML = '<div class="loading-spinner me-2"></div>{{ _('Resuming...') }}';
|
|
const res = await fetch('/api/timer/resume', { method:'POST', headers:{ 'Content-Type':'application/json' }, credentials:'same-origin', body: JSON.stringify({}) });
|
|
const json = await res.json();
|
|
if (!json.success) throw new Error(json.error || 'fail');
|
|
showToast('{{ _('Timer resumed') }}', 'success');
|
|
checkTimerStatus();
|
|
loadRecentEntries();
|
|
} catch(e) { showToast('{{ _('Nothing to resume') }}', 'warning'); }
|
|
finally { btn.disabled = false; btn.innerHTML = '<i class="fas fa-redo"></i>{{ _('Resume Last') }}'; }
|
|
});
|
|
});
|
|
|
|
// Refresh data periodically
|
|
setInterval(() => {
|
|
loadRecentEntries();
|
|
}, 30000); // Every 30 seconds
|
|
|
|
// Handle delete confirmation modal
|
|
document.getElementById('confirmDeleteBtn').addEventListener('click', function() {
|
|
const entryId = window.pendingDeleteEntryId;
|
|
const button = window.pendingDeleteButton;
|
|
|
|
if (!entryId) return;
|
|
|
|
// Show loading state if button exists
|
|
if (button) {
|
|
button.innerHTML = '<div class="loading-spinner me-2"></div>{{ _('Deleting...') }}';
|
|
button.disabled = true;
|
|
}
|
|
|
|
fetch(`/api/entry/${entryId}`, {
|
|
method: 'DELETE',
|
|
credentials: 'same-origin'
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
showToast('{{ _('Entry deleted successfully') }}', 'success');
|
|
|
|
// Hide modals
|
|
bootstrap.Modal.getInstance(document.getElementById('deleteEntryModal')).hide();
|
|
if (button) {
|
|
bootstrap.Modal.getInstance(document.getElementById('editTimerModal')).hide();
|
|
}
|
|
|
|
// Refresh data
|
|
loadRecentEntries();
|
|
if (button) {
|
|
checkTimerStatus();
|
|
}
|
|
} else {
|
|
showToast(data.message || '{{ _('Error deleting entry') }}', 'error');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error deleting entry:', error);
|
|
showToast('{{ _('Error deleting entry') }}', 'error');
|
|
})
|
|
.finally(() => {
|
|
// Reset button state if it exists
|
|
if (button) {
|
|
button.innerHTML = '<i class="fas fa-trash me-1"></i>{{ _('Delete') }}';
|
|
button.disabled = false;
|
|
}
|
|
|
|
// Clear stored data
|
|
window.pendingDeleteEntryId = null;
|
|
window.pendingDeleteButton = null;
|
|
});
|
|
});
|
|
});
|
|
// Focus: session lifecycle
|
|
document.getElementById('startFocusSessionBtn').addEventListener('click', async function(){
|
|
const payload = {
|
|
pomodoro_length: Number(document.getElementById('pomodoroLen').value || 25),
|
|
short_break_length: Number(document.getElementById('shortBreakLen').value || 5),
|
|
long_break_length: Number(document.getElementById('longBreakLen').value || 15),
|
|
long_break_interval: Number(document.getElementById('longBreakEvery').value || 4),
|
|
link_active_timer: document.getElementById('linkActiveTimer').checked
|
|
};
|
|
const res = await fetch('/api/focus-sessions/start', { method:'POST', headers:{ 'Content-Type':'application/json' }, credentials:'same-origin', body: JSON.stringify(payload) });
|
|
const json = await res.json();
|
|
if (!json.success) { showToast(json.error || '{{ _('Failed to start focus session') }}', 'danger'); return; }
|
|
const session = json.session; window.__focusSessionId = session.id;
|
|
showToast('{{ _('Focus session started') }}', 'success');
|
|
bootstrap.Modal.getInstance(document.getElementById('focusModal')).hide();
|
|
// Simple countdown display under timer
|
|
const summary = document.getElementById('focusSummary');
|
|
if (summary) summary.textContent = '';
|
|
});
|
|
// When modal hidden, if session running we do nothing; finishing handled manually
|
|
});
|
|
|
|
// Optional: expose a finish function to be called by UI elsewhere
|
|
async function finishFocusSession(cyclesCompleted = 0, interruptions = 0, notes = ''){
|
|
if (!window.__focusSessionId) return;
|
|
try {
|
|
const res = await fetch('/api/focus-sessions/finish', { method:'POST', headers:{ 'Content-Type':'application/json' }, credentials:'same-origin', body: JSON.stringify({ session_id: window.__focusSessionId, cycles_completed: cyclesCompleted, interruptions: interruptions, notes }) });
|
|
const json = await res.json();
|
|
if (!json.success) throw new Error(json.error || 'fail');
|
|
showToast('{{ _('Focus session saved') }}', 'success');
|
|
} catch(e) { showToast('{{ _('Failed to save focus session') }}', 'danger'); }
|
|
finally { window.__focusSessionId = null; }
|
|
}
|
|
|
|
// Load focus sessions summary when opening modal
|
|
document.getElementById('openFocusBtn').addEventListener('click', async function(){
|
|
try {
|
|
const res = await fetch('/api/focus-sessions/summary?days=7', { credentials: 'same-origin' });
|
|
const json = await res.json();
|
|
const el = document.getElementById('focusHistory');
|
|
if (el && json) {
|
|
el.textContent = `${json.total_sessions || 0} {{ _('sessions') }}, ${json.cycles_completed || 0} {{ _('cycles') }}, ${json.interruptions || 0} {{ _('interruptions') }}`;
|
|
}
|
|
} catch(e) {}
|
|
});
|
|
</script>
|
|
{% endblock %}
|