better task complete animation
This commit is contained in:
6
app.py
6
app.py
@@ -95,14 +95,12 @@ def sync_check():
|
|||||||
|
|
||||||
entry_id = str(current_entry['_id']) if current_entry else 'none'
|
entry_id = str(current_entry['_id']) if current_entry else 'none'
|
||||||
|
|
||||||
# Create simple hash of tasks (IDs joined string)
|
# Return list of IDs for smart diffing on frontend
|
||||||
# If a task is completed, it drops from this list -> hash changes -> client reloads
|
|
||||||
task_ids = sorted([str(t['_id']) for t in active_tasks])
|
task_ids = sorted([str(t['_id']) for t in active_tasks])
|
||||||
tasks_hash = ','.join(task_ids)
|
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'entry_hash': entry_id,
|
'entry_hash': entry_id,
|
||||||
'tasks_hash': tasks_hash
|
'task_ids': task_ids
|
||||||
})
|
})
|
||||||
|
|
||||||
@app.route('/register', methods=['GET', 'POST'])
|
@app.route('/register', methods=['GET', 'POST'])
|
||||||
|
|||||||
@@ -57,6 +57,27 @@
|
|||||||
/* Mobile specific timer adjustments */
|
/* Mobile specific timer adjustments */
|
||||||
.timer-display { font-size: 2.5rem !important; margin: 0.5rem 0 1rem 0 !important; }
|
.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>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -166,9 +187,9 @@
|
|||||||
{% if tasks %}
|
{% if tasks %}
|
||||||
<div style="margin-bottom: 2rem;">
|
<div style="margin-bottom: 2rem;">
|
||||||
<h4 style="font-size: 0.8rem; text-transform: uppercase; color: var(--text-secondary); letter-spacing: 0.05em; border-bottom: 1px solid var(--border-dim); padding-bottom: 5px;">Active Tasks</h4>
|
<h4 style="font-size: 0.8rem; text-transform: uppercase; color: var(--text-secondary); letter-spacing: 0.05em; border-bottom: 1px solid var(--border-dim); padding-bottom: 5px;">Active Tasks</h4>
|
||||||
<div class="checklist-container">
|
<div class="checklist-container" id="activeTasksList">
|
||||||
{% for task in tasks %}
|
{% for task in tasks %}
|
||||||
<div class="checklist-item {% if task.status == 'completed' %}completed{% endif %}">
|
<div class="checklist-item {% if task.status == 'completed' %}completed{% endif %}" id="task-item-{{ task._id }}">
|
||||||
<input type="checkbox" id="task_{{ task._id }}"
|
<input type="checkbox" id="task_{{ task._id }}"
|
||||||
{% if task.status == 'completed' %}checked{% endif %}
|
{% if task.status == 'completed' %}checked{% endif %}
|
||||||
onchange="toggleTask('{{ task._id }}', this)">
|
onchange="toggleTask('{{ task._id }}', this)">
|
||||||
@@ -223,22 +244,38 @@
|
|||||||
updateTimer();
|
updateTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
// AJAX for Tasks
|
// AJAX for Tasks with Animation
|
||||||
function toggleTask(taskId, checkbox) {
|
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();
|
const formData = new FormData();
|
||||||
formData.append('task_id', taskId);
|
formData.append('task_id', taskId);
|
||||||
formData.append('is_checked', checkbox.checked);
|
formData.append('is_checked', checkbox.checked);
|
||||||
|
|
||||||
fetch('/complete_task', { method: 'POST', body: formData })
|
// Remove from our local "known" list so Sync doesn't freak out, actually Sync wants matches.
|
||||||
.then(r => r.json())
|
// We let Sync reload page eventually to clean up DOM purely
|
||||||
.then(data => {
|
fetch('/complete_task', { method: 'POST', body: formData });
|
||||||
const item = checkbox.closest('.checklist-item');
|
|
||||||
if(checkbox.checked) {
|
|
||||||
item.classList.add('completed');
|
|
||||||
} else {
|
|
||||||
item.classList.remove('completed');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modal & Start Logic
|
// Modal & Start Logic
|
||||||
@@ -375,27 +412,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Live Sync Script -->
|
<!-- Live Sync Script (Optimized for Animation) -->
|
||||||
<script>
|
<script>
|
||||||
// Generate initial state hashes based on what Jinja rendered
|
// Generate initial state hashes based on what Jinja rendered
|
||||||
const initialEntryHash = "{{ current_entry._id if current_entry else 'none' }}";
|
const initialEntryHash = "{{ current_entry._id if current_entry else 'none' }}";
|
||||||
// Create comma-separated string of IDs. Since IDs are 24 chars, sorting shouldn't matter for Jinja vs Python if order preserved,
|
|
||||||
// but app.py sorts. Let's trust they match initially or accept one quick reload on first poll if order differs.
|
|
||||||
// Actually, dashboard displays in list order. Python sorted them for hash.
|
|
||||||
// Let's rely on the Python endpoint's logic. We just need to know if we need to reload.
|
|
||||||
// To avoid immediate reload, we can fetch the initial hash via API or just accept one reload.
|
|
||||||
// Better: let's store the raw IDs from jinja.
|
|
||||||
|
|
||||||
let localEntryHash = initialEntryHash;
|
let localEntryHash = initialEntryHash;
|
||||||
|
|
||||||
// Construct local task hash manually to match Python's sorted logic
|
// Construct local task list
|
||||||
let taskIds = [
|
let localTaskIds = [
|
||||||
{% for t in tasks %}
|
{% for t in tasks %}
|
||||||
"{{ t._id }}",
|
"{{ t._id }}",
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
];
|
];
|
||||||
taskIds.sort();
|
|
||||||
let localTasksHash = taskIds.join(',');
|
|
||||||
|
|
||||||
// Polling function
|
// Polling function
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
@@ -412,12 +441,58 @@
|
|||||||
})
|
})
|
||||||
.then(data => {
|
.then(data => {
|
||||||
const serverEntryHash = data.entry_hash;
|
const serverEntryHash = data.entry_hash;
|
||||||
const serverTasksHash = data.tasks_hash;
|
const serverTaskIds = data.task_ids;
|
||||||
|
|
||||||
|
// 1. Entry Changed? Priority Reload.
|
||||||
|
if (serverEntryHash !== localEntryHash) {
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Compare
|
// 2. Diff Tasks
|
||||||
if (serverEntryHash !== localEntryHash || serverTasksHash !== localTasksHash) {
|
// Find tasks that are in Local but NOT in Server (Remote Completion)
|
||||||
console.log("State changed remotely. Syncing...");
|
const missingIds = localTaskIds.filter(id => !serverTaskIds.includes(id));
|
||||||
// Reload to reflect changes (Timer started/stopped on other device, or task completed)
|
// 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();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user