Files
OpenTimeTracker/templates/dashboard.html

684 lines
30 KiB
HTML

{% extends "layout.html" %}
{% block content %}
<style>
/* Specific Dashboard Mobile Fixes */
@media (max-width: 768px) {
/* Grid Layout for Mobile: Strict 2 columns */
.activity-grid {
display: grid !important;
grid-template-columns: 1fr 1fr !important;
gap: 1rem !important;
justify-content: stretch;
width: 100%;
}
/* Reset child width constraints from flex logic */
.activity-grid > div {
width: auto !important;
min-width: 0 !important;
max-width: none !important;
}
/* Fix container widths to be full width */
.main-content {
text-align: center;
width: 100% !important; /* Force full width */
flex: none !important; /* Disable flex scaling from desktop */
min-width: 0 !important;
}
.main-content h2 {
margin-left: auto;
margin-right: auto;
}
/* Layout Reordering: Sidebar (Active Timer) first */
.dashboard-container {
flex-direction: column;
align-items: stretch !important; /* Force children to stretch full width */
}
.sidebar-container {
order: -1; /* Visualize first */
flex: 0 0 auto !important;
width: 100% !important;
position: static !important; /* Reset sticky/fixed */
margin-bottom: 2rem;
min-width: 0 !important; /* Override inline style */
}
.active-section {
/* Reset fixed styles from previous iteration if any */
position: static !important;
margin: 0 !important;
box-shadow: none !important;
border: 1px solid var(--border-dim) !important;
}
/* Mobile specific timer adjustments */
.timer-display { font-size: 2.5rem !important; margin: 0.5rem 0 1rem 0 !important; }
}
/* Task Animations */
.checklist-item {
transition: transform 0.4s ease, opacity 0.4s ease, background-color 0.3s;
opacity: 1;
transform: translateX(0);
}
/* State: Just finished animating out */
.checklist-item.fade-out-complete {
opacity: 0;
transform: translateX(20px);
pointer-events: none;
}
/* State: Currently animating out (optional differentiation) */
.checklist-item.animating-out {
opacity: 0.5;
background-color: #f9f9f9;
text-decoration: line-through;
}
</style>
<script>
// Helper to toggle body class for spacing (Keeping logic but simplifying CSS impact)
function setTrackingState(isTracking) {
if(isTracking) document.body.classList.add('is-tracking');
else document.body.classList.remove('is-tracking');
}
</script>
<!-- Added class dashboard-container for flex control -->
<div class="dashboard-container" style="display: flex; flex-wrap: wrap; gap: 2rem; align-items: flex-start;">
<!-- Left Column: Activity Grid (Main Content) -->
<div class="main-content" style="flex: 1; min-width: 300px; max-width: 100%;">
<h2 style="margin-bottom: 1.5rem;">Start Tracking</h2>
<div class="activity-grid">
{% for act in activities %}
<div style="position: relative;">
<div class="activity-card"
style="background-color: {{ act.color }}"
data-name="{{ act.name }}"
data-id="{{ act._id }}"
data-color="{{ act.color }}"
data-subcategories='{{ act.subcategories|default([])|tojson }}'
onclick="startActivityImmediate(this)">
<span>{{ act.name }}</span>
</div>
<!-- Small Edit Link -->
<a href="{{ url_for('edit_activity', activity_id=act._id) }}"
style="position: absolute; top: 5px; right: 5px; color: rgba(255,255,255,0.8); text-decoration: none; font-size: 0.8rem; padding: 5px;"
title="Edit Activity">
Edit
</a>
</div>
{% endfor %}
<!-- Add New Button -->
<div style="position: relative;">
<div class="activity-card" style="background-color: #95a5a6; color: white;" onclick="document.getElementById('newActivityForm').style.display='block'">
<span>+ New Activity</span>
</div>
</div>
</div>
<!-- Create Activity Form -->
<div id="newActivityForm" class="card" style="display:none; margin-top: 2rem; text-align: left;">
<h3>Create New Activity Category</h3>
<form action="{{ url_for('add_activity') }}" method="POST" onsubmit="prepareTasksList(); prepareSubcatsList();">
<label>Name</label>
<input type="text" name="name" required placeholder="e.g. Household">
<label>Color</label>
<input type="color" name="color" value="#3498db" style="width:100%; height:40px; border:none;">
<label>Subcategories / Contexts</label>
<p style="font-size: 0.8rem; color: var(--text-secondary); margin-top: -10px;">
Define specific contexts (e.g. Client A, Project X). You can pick one when starting the activity.
</p>
<div style="display: flex; gap: 5px; margin-bottom: 10px;">
<input type="text" id="newSubcatInput" placeholder="Add subcategory..." style="margin-bottom: 0;">
<button type="button" class="btn" style="background: #27ae60;" onclick="addNewSubcat()">Add</button>
</div>
<ul id="newSubcatsListDisplay" style="list-style: none; padding: 0; margin-bottom: 1rem;"></ul>
<input type="hidden" name="subcategories_data" id="subcatsListData">
<label>Default Tasks (Template)</label>
<p style="font-size: 0.8rem; color: var(--text-secondary); margin-top: -10px;">
These tasks will be created automatically every time you start this activity.
</p>
<div style="display: flex; gap: 5px; margin-bottom: 10px;">
<input type="text" id="newTaskInput" placeholder="Add task name..." style="margin-bottom: 0;">
<button type="button" class="btn" style="background: #27ae60;" onclick="addNewTask()">Add</button>
</div>
<ul id="newTasksListDisplay" style="list-style: none; padding: 0; margin-bottom: 1rem;"></ul>
<input type="hidden" name="tasks_list_data" id="tasksListData">
<div style="margin-top: 1rem;">
<button type="submit" class="btn">Create</button>
<button type="button" class="btn" style="background: var(--text-secondary)" onclick="document.getElementById('newActivityForm').style.display='none'">Cancel</button>
</div>
</form>
</div>
</div>
<!-- Right Column: Sidebar (Active Timer) -->
<!-- Only visible if tracking, or hidden via JS initially if not tracking but we use 'display:none' on the card itself -->
<div style="flex: 0 0 380px; position: sticky; top: 100px; max-width: 100%;" class="sidebar-container">
<div class="card active-section" id="activeSection" style="{% if not current_entry %}display:none;{% endif %} padding: 1.5rem;">
<div style="display:flex; justify-content: space-between; align-items: start;">
<div style="text-align: center; flex: 1;">
<small style="text-transform: uppercase; font-weight: bold; color: var(--primary-color);">Currently Tracking</small>
<h3 id="activeName" style="margin: 0.5rem 0; color: {{ current_entry.activity_color if current_entry else '#333' }}">
{{ current_entry.activity_name if current_entry else '' }}
</h3>
</div>
<!-- Collapse Button for Mobile (Optional, keeping simple for now) -->
</div>
<!-- Context Info -->
<div id="activeSubcatContainer" style="text-align: center; {% if not current_entry or not current_entry.subcategory %}display:none;{% endif %} margin-bottom: 10px;">
<span id="activeSubcatDisplay" style="background: var(--bg-input); padding: 4px 12px; border-radius: 12px; font-size: 0.85rem; color: var(--text-secondary);">
{{ current_entry.subcategory if current_entry else '' }}
</span>
</div>
<p id="activeNote" style="text-align: center; color: var(--text-secondary); font-style: italic; font-size: 0.9rem; margin-bottom: 1.5rem; {% if not current_entry or not current_entry.note %}display:none;{% endif %}">
"{{ current_entry.note if current_entry else '' }}"
</p>
<div class="timer-display" id="timer" style="margin: 1rem 0 2rem 0;">00:00:00</div>
<!-- Tasks List (Redesigned) -->
<div style="margin-bottom: 2rem;">
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-dim); padding-bottom: 5px; margin-bottom: 10px;">
<h4 style="font-size: 0.8rem; text-transform: uppercase; color: var(--text-secondary); letter-spacing: 0.05em; margin: 0;">Active Tasks</h4>
<!-- Quick Add Button -->
<button type="button" onclick="openQuickTaskModal()" style="background: transparent; border: 1px solid var(--border-dim); cursor: pointer; border-radius: 50%; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; color: var(--text-secondary); margin-left: 10px;" title="Add Task">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
</button>
</div>
<div class="checklist-container" id="activeTasksList">
{% if tasks %}
{% for task in tasks %}
<div class="checklist-item {% if task.status == 'completed' %}completed{% endif %}" id="task-item-{{ task._id }}">
<input type="checkbox" id="task_{{ task._id }}"
{% if task.status == 'completed' %}checked{% endif %}
onchange="toggleTask('{{ task._id }}', this)">
<label for="task_{{ task._id }}">
<a href="{{ url_for('task_detail', task_id=task._id) }}" style="text-decoration: none; color: inherit;">
{{ task.name }}
</a>
</label>
</div>
{% endfor %}
{% endif %}
</div>
</div>
<form action="{{ url_for('stop_timer') }}" method="POST">
<button type="submit" class="btn btn-danger" style="width: 100%; justify-content: center; padding: 12px;">Stop Activity</button>
</form>
</div>
</div>
</div>
<!-- Scripts (Timer, Tasks, Modal) -->
<script>
// Global vars - MUST be declared before any functions that use them
let currentActivityId = "{{ current_entry.activity_id if current_entry else '' }}";
let currentSubcategory = "{{ current_entry.subcategory if current_entry else '' }}";
let newActivitySubcats = [];
let newActivityTasks = [];
// Timer Logic
let startTime = {% if current_entry %}{{ current_entry.start_time.timestamp() * 1000 }}{% else %}null{% endif %};
let timerInterval;
// Initial state check for padding
if(startTime) setTrackingState(true);
function updateTimer() {
if (!startTime) return;
const now = new Date().getTime();
const diff = now - startTime;
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
const timerEl = document.getElementById("timer");
if (timerEl) {
timerEl.innerHTML =
(hours < 10 ? "0" + hours : hours) + ":" +
(minutes < 10 ? "0" + minutes : minutes) + ":" +
(seconds < 10 ? "0" + seconds : seconds);
}
}
if (startTime) {
timerInterval = setInterval(updateTimer, 1000);
updateTimer();
}
// AJAX for Tasks with Animation
function toggleTask(taskId, checkbox) {
const item = checkbox.closest('.checklist-item');
// 1. Immediately apply visual "done" state
if(checkbox.checked) {
item.classList.add('completed');
item.classList.add('animating-out'); // Marker for sync script to ignore
// 2. Move to bottom of list
const container = document.getElementById('activeTasksList');
container.appendChild(item); // Moves to end
// 3. Fade out slowly
setTimeout(() => {
item.classList.add('fade-out-complete');
}, 600); // Slight delay after move
} else {
item.classList.remove('completed');
item.classList.remove('fade-out-complete');
item.classList.remove('animating-out');
}
// 4. Send Request
const formData = new FormData();
formData.append('task_id', taskId);
formData.append('is_checked', checkbox.checked);
// Remove from our local "known" list so Sync doesn't freak out, actually Sync wants matches.
// We let Sync reload page eventually to clean up DOM purely
fetch('/complete_task', { method: 'POST', body: formData });
}
// Modal & Start Logic
function closeStartModal() {
document.getElementById('startModal').classList.remove('show');
}
function closeQuickTaskModal() {
document.getElementById('quickTaskModal').classList.remove('show');
document.getElementById('quickTaskName').value = ''; // Clean up
}
// Close on backdrop click
document.getElementById('startModal').addEventListener('click', function(e) {
if (e.target === this) closeStartModal();
});
document.getElementById('quickTaskModal').addEventListener('click', function(e) {
if (e.target === this) closeQuickTaskModal();
});
// Start Activity Immediate Function
function startActivityImmediate(element) {
const id = element.getAttribute('data-id');
const name = element.getAttribute('data-name');
const color = element.getAttribute('data-color');
const subcats = JSON.parse(element.getAttribute('data-subcategories') || '[]');
// Save ID globally for quick add
currentActivityId = id;
// 1. Start Background Timer
fetch('/start_timer_bg/' + id, { method: 'POST' })
.then(res => res.json())
.then(data => {
if(data.status === 'success') {
// UPDATE LOCAL HASH TO PREVENT SYNC RELOAD
if (data.entry_id) {
localEntryHash = data.entry_id;
console.log("Local state updated intentionally. Reload prevented.");
}
// 2. Start Local Timer UI
startTime = new Date(data.start_time).getTime();
clearInterval(timerInterval);
timerInterval = setInterval(updateTimer, 1000);
updateTimer();
// Update UI State
document.getElementById('activeSection').style.display = 'block';
setTrackingState(true); // Add padding for mobile
document.getElementById('activeName').innerText = name;
document.getElementById('activeName').style.color = color;
// Reset Note/Subcat display
document.getElementById('activeNote').style.display = 'none';
document.getElementById('activeSubcatContainer').style.display = 'none';
document.getElementById('activeSubcatDisplay').innerText = ''; // Reset text
currentSubcategory = ""; // Reset global
// Clear tasks list visually on new start (will define via sync or reload if templates exist)
// Ideally we'd fetch templates here, but sync will catch them in 2s.
// For smoother UX, we could inject template names if passed from backend.
document.getElementById('activeTasksList').innerHTML = '';
localTaskIds = []; // Reset local known tasks
// 3. Open Modal for Details
openDetailsModal(name, subcats);
}
});
}
// Hook into form update to set these values when modal is submitted/skipped
function openDetailsModal(name, subcats) {
document.getElementById('modalTitle').innerText = name + " Started";
// Setup Subcategories
const subcatContainer = document.getElementById('subcategoryContainer');
const select = document.getElementById('modalSubcatSelect');
select.innerHTML = '<option value="">-- None --</option>';
if (subcats && subcats.length > 0) {
subcatContainer.style.display = 'block';
subcats.forEach(sc => {
const opt = document.createElement('option');
opt.value = sc;
opt.innerText = sc;
select.appendChild(opt);
});
} else {
subcatContainer.style.display = 'none';
}
// Show Modal
document.getElementById('startModal').classList.add('show');
document.querySelector('#startForm input[name="note"]').focus();
}
// Open Quick Task Modal
function openQuickTaskModal() {
if(!currentActivityId) {
alert("No active activity context found. Please start an activity first.");
return;
}
document.getElementById('quickTaskModal').classList.add('show');
document.getElementById('quickTaskName').focus();
}
// Quick Add Form Submit
function submitQuickTask(e) {
e.preventDefault();
const input = document.getElementById('quickTaskName');
const val = input.value.trim();
if(!val) return;
const formData = new FormData();
formData.append('name', val);
formData.append('activity_id', currentActivityId);
formData.append('subcategory', currentSubcategory);
fetch('/create_task_quick', { method: 'POST', body: formData })
.then(r => r.json())
.then(data => {
if(data.status === 'success') {
input.value = '';
closeQuickTaskModal();
// Add to DOM
const list = document.getElementById('activeTasksList');
const div = document.createElement('div');
div.className = 'checklist-item';
div.id = 'task-item-' + data.task_id;
div.innerHTML = `
<input type="checkbox" id="task_${data.task_id}"
onchange="toggleTask('${data.task_id}', this)">
<label for="task_${data.task_id}">
<a href="/task/${data.task_id}" style="text-decoration: none; color: inherit;">
${data.name}
</a>
</label>
`;
list.insertBefore(div, list.firstChild); // Add to top
// Register ID so sync doesn't reload
localTaskIds.push(data.task_id);
console.log("Quick task added. Sync updated.");
}
});
}
// Subcategory List Logic for Activity Creation form
function addNewSubcat() {
const input = document.getElementById('newSubcatInput');
const val = input.value.trim();
if (val) {
newActivitySubcats.push(val);
input.value = '';
renderNewActivitySubcats();
}
}
function removeNewSubcat(index) {
newActivitySubcats.splice(index, 1);
renderNewActivitySubcats();
}
function renderNewActivitySubcats() {
const list = document.getElementById('newSubcatsListDisplay');
list.innerHTML = '';
newActivitySubcats.forEach((item, index) => {
const li = document.createElement('li');
li.style.background = '#f7f7f5';
li.style.border = '1px solid var(--border-dim)';
li.style.margin = '5px 0';
li.style.padding = '8px';
li.style.borderRadius = '4px';
li.style.display = 'flex';
li.style.justifyContent = 'space-between';
li.style.alignItems = 'center';
li.style.fontSize = '0.9rem';
li.innerHTML = `<span>${item}</span><span onclick="removeNewSubcat(${index})" style="cursor: pointer; color: var(--danger-color); font-weight: bold; padding: 0 5px;">&times;</span>`;
list.appendChild(li);
});
}
function prepareSubcatsList() {
document.getElementById('subcatsListData').value = newActivitySubcats.join(',');
}
// New Task List Logic for Activity Creation form
function addNewTask() {
const input = document.getElementById('newTaskInput');
const val = input.value.trim();
if (val) {
newActivityTasks.push(val);
input.value = '';
renderNewActivityTasks();
}
}
function removeNewTask(index) {
newActivityTasks.splice(index, 1);
renderNewActivityTasks();
}
function renderNewActivityTasks() {
const list = document.getElementById('newTasksListDisplay');
list.innerHTML = '';
newActivityTasks.forEach((item, index) => {
const li = document.createElement('li');
li.style.background = '#f7f7f5';
li.style.border = '1px solid var(--border-dim)';
li.style.margin = '5px 0';
li.style.padding = '8px';
li.style.borderRadius = '4px';
li.style.display = 'flex';
li.style.justifyContent = 'space-between';
li.style.alignItems = 'center';
li.style.fontSize = '0.9rem';
li.innerHTML = `<span>${item}</span><span onclick="removeNewTask(${index})" style="cursor: pointer; color: var(--danger-color); font-weight: bold; padding: 0 5px;">&times;</span>`;
list.appendChild(li);
});
}
function prepareTasksList() {
document.getElementById('tasksListData').value = newActivityTasks.join(',');
}
</script>
<!-- Custom Start Modal (Hidden by default) -->
<div id="startModal" class="modal-backdrop">
<div class="modal-card">
<h3 id="modalTitle" style="margin-bottom: 1.5rem;">Activity Started</h3>
<form id="startForm" onsubmit="submitDetailsForm(event)">
<div id="subcategoryContainer" style="display: none; margin-bottom: 1rem;">
<label>Subcategory / Context</label>
<select name="subcategory" id="modalSubcatSelect" style="width: 100%; padding: 0.5rem;">
<option value="">-- None --</option>
</select>
</div>
<label>Note (Optional)</label>
<input type="text" name="note" id="modalNote" placeholder="What are you working on?" autocomplete="off">
<div style="margin-top: 2rem; display: flex; justify-content: space-between;">
<button type="button" class="btn" style="background: transparent; color: var(--text-secondary); border: 1px solid var(--border-dim);" onclick="closeStartModal()">Skip</button>
<button type="submit" class="btn" style="background: #27ae60;">Update Details</button>
</div>
</form>
</div>
</div>
<!-- Quick Task Modal (For adding task to active session) -->
<div id="quickTaskModal" class="modal-backdrop">
<div class="modal-card">
<h3 style="margin-bottom: 1.5rem;">Add Task</h3>
<p style="color: var(--text-secondary); margin-bottom: 1rem; font-size: 0.9rem;">
This task will be linked to the current activity.
</p>
<form onsubmit="submitQuickTask(event)">
<label>Task Name</label>
<input type="text" id="quickTaskName" required placeholder="What needs to be done?" autocomplete="off">
<div style="margin-top: 2rem; display: flex; justify-content: flex-end; gap: 10px;">
<button type="button" class="btn" style="background: transparent; color: var(--text-secondary); border: 1px solid var(--border-dim);" onclick="closeQuickTaskModal()">Cancel</button>
<button type="submit" class="btn" style="background: #27ae60;">Add Task</button>
</div>
</form>
</div>
</div>
<script>
// Replace standard form submit for details so page doesn't reload and lose state
function submitDetailsForm(e) {
e.preventDefault();
const sub = document.getElementById('modalSubcatSelect').value;
const note = document.getElementById('modalNote').value;
currentSubcategory = sub; // Update global for quick add task
// Update UI immediately
if(sub) {
document.getElementById('activeSubcatContainer').style.display = 'block';
document.getElementById('activeSubcatDisplay').innerText = sub;
}
if(note) {
document.getElementById('activeNote').style.display = 'block';
document.getElementById('activeNote').innerText = '"' + note + '"';
}
const fd = new FormData();
fd.append('subcategory', sub);
fd.append('note', note);
fetch('/update_active_entry', { method: 'POST', body: fd });
closeStartModal();
}
</script>
<!-- Live Sync Script (Optimized for Animation) -->
<script>
// Generate initial state hashes based on what Jinja rendered
const initialEntryHash = "{{ current_entry._id if current_entry else 'none' }}";
let localEntryHash = initialEntryHash;
// Construct local task list
let localTaskIds = [
{% for t in tasks %}
"{{ t._id }}",
{% endfor %}
];
// Polling function
setInterval(() => {
// Don't poll if page is hidden to save battery/data
if (document.hidden) return;
// Don't poll or reload if ANY Modal is open (User is interacting)
if (document.getElementById('startModal').classList.contains('show') ||
document.getElementById('quickTaskModal').classList.contains('show')) return;
fetch('/api/sync_check')
.then(response => {
if(response.status === 401) window.location.reload(); // Auth lost
return response.json();
})
.then(data => {
const serverEntryHash = data.entry_hash;
const serverTaskIds = data.task_ids;
// 1. Entry Changed? Priority Reload.
if (serverEntryHash !== localEntryHash) {
window.location.reload();
return;
}
// 2. Diff Tasks
// Find tasks that are in Local but NOT in Server (Remote Completion)
const missingIds = localTaskIds.filter(id => !serverTaskIds.includes(id));
// Find tasks that are in Server but NOT in Local (Remote Addition)
const addedIds = serverTaskIds.filter(id => !localTaskIds.includes(id));
let needsReload = false;
// Handle Missing (Completed Remotely)
if (missingIds.length > 0) {
let animationTriggered = false;
missingIds.forEach(id => {
const el = document.getElementById(`task-item-${id}`);
// Only animate if it exists and isn't already animating locally
if (el && !el.classList.contains('animating-out') && !el.classList.contains('fade-out-complete')) {
el.classList.add('completed');
el.classList.add('fade-out-complete');
animationTriggered = true;
}
});
// Update our local truth to stop re-detecting
localTaskIds = localTaskIds.filter(id => !missingIds.includes(id));
// If we animated something out, wait briefly then reload to clean DOM or just leave it hidden
// User wants "smoother". Reloading jerks.
// Since the element is hidden via CSS, we technically don't HAVE to reload immediately
// unless we want to remove the DOM node.
// Let's remove the DOM node after animation and NOT reload if that's the only change.
if (animationTriggered) {
setTimeout(() => {
missingIds.forEach(id => {
const el = document.getElementById(`task-item-${id}`);
if(el) el.remove();
});
}, 500);
return; // Skip reload!
} else {
// If it was already missing or animating, maybe we do nothing or ensure it's gone
}
}
if (addedIds.length > 0) {
console.log("New tasks detected. Syncing...");
window.location.reload();
}
})
.catch(err => console.error("Sync check failed", err));
}, 2000); // 2 seconds
</script>
{% endblock %}