mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2025-12-31 00:09:58 -06:00
Add Pomodoro focus mode with session summaries Model: FocusSession; API: /api/focus-sessions/; UI: Focus modal on timer page Add estimates vs actuals with burndown and budget alerts Project fields: estimated_hours, budget_amount, budget_threshold_percent API: /api/projects/<id>/burndown; Charts in project view and project report Implement recurring time blocks/templates Model: RecurringBlock; API CRUD: /api/recurring-blocks; CLI: flask generate_recurring Add tagging and saved filters across views Model: SavedFilter; /api/entries supports tag and saved_filter_id Support billable rate overrides per project/member Model: RateOverride; invoicing uses effective rate resolution Also: Migration: 016_add_focus_recurring_rates_filters_and_project_budget.py Integrations and UI updates in projects view, timer page, and reports Docs updated (startup, invoice, task mgmt) and README feature list Added basic tests for new features
832 lines
37 KiB
HTML
832 lines
37 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>
|
|
</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>
|
|
</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>
|
|
<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();
|
|
}
|
|
})
|
|
.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();
|
|
|
|
// 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; }
|
|
}
|
|
</script>
|
|
{% endblock %}
|