feat(board): add live task countdowns
This commit is contained in:
@@ -16,7 +16,7 @@
|
|||||||
var dueAt = task.DueAt!.Value;
|
var dueAt = task.DueAt!.Value;
|
||||||
var dueAtUtc = dueAt.ToUniversalTime().ToString("O");
|
var dueAtUtc = dueAt.ToUniversalTime().ToString("O");
|
||||||
var localDueAt = dueAt.ToLocalTime().ToString("ddd, MMM d, h:mm tt");
|
var localDueAt = dueAt.ToLocalTime().ToString("ddd, MMM d, h:mm tt");
|
||||||
<article class="upcoming-task"
|
<article class="upcoming-task" data-due-at="@dueAtUtc"
|
||||||
hx-get="/board?handler=TaskDetail&id=@task.Id"
|
hx-get="/board?handler=TaskDetail&id=@task.Id"
|
||||||
hx-target="#detail-panel"
|
hx-target="#detail-panel"
|
||||||
hx-swap="innerHTML">
|
hx-swap="innerHTML">
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
<span>@task.Category</span>
|
<span>@task.Category</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<time class="task-countdown" aria-live="off" data-due-at="@dueAtUtc" datetime="@dueAtUtc" title="@localDueAt">@localDueAt</time>
|
<time class="task-countdown" aria-live="off" datetime="@dueAtUtc" title="@localDueAt">@localDueAt</time>
|
||||||
</article>
|
</article>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -55,6 +55,50 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Upcoming task countdowns
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
var countdownTimer = null;
|
||||||
|
|
||||||
|
function formatCountdown(totalSeconds, isFuture) {
|
||||||
|
var remaining = totalSeconds;
|
||||||
|
var days = Math.floor(remaining / 86400);
|
||||||
|
remaining %= 86400;
|
||||||
|
var hours = Math.floor(remaining / 3600);
|
||||||
|
remaining %= 3600;
|
||||||
|
var minutes = Math.floor(remaining / 60);
|
||||||
|
var seconds = remaining % 60;
|
||||||
|
var parts = [];
|
||||||
|
|
||||||
|
if (days) parts.push(days + 'd');
|
||||||
|
if (hours) parts.push(hours + 'h');
|
||||||
|
if (minutes) parts.push(minutes + 'm');
|
||||||
|
if (seconds && !days && !hours) parts.push(seconds + 's');
|
||||||
|
if (parts.length === 0) parts.push('0s');
|
||||||
|
|
||||||
|
return isFuture ? parts.join(' ') + ' remaining' : 'Overdue by ' + parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCountdowns() {
|
||||||
|
var now = Date.now();
|
||||||
|
document.querySelectorAll('[data-due-at] .task-countdown, time.task-countdown[data-due-at]').forEach(function(countdown) {
|
||||||
|
var dueAtElement = countdown.closest('[data-due-at]') || countdown;
|
||||||
|
var dueAt = Date.parse(dueAtElement.dataset.dueAt);
|
||||||
|
if (Number.isNaN(dueAt)) return;
|
||||||
|
|
||||||
|
var deltaMilliseconds = dueAt - now;
|
||||||
|
var totalSeconds = Math.floor(Math.abs(deltaMilliseconds) / 1000);
|
||||||
|
countdown.textContent = formatCountdown(totalSeconds, deltaMilliseconds > 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initCountdowns() {
|
||||||
|
if (countdownTimer) clearInterval(countdownTimer);
|
||||||
|
updateCountdowns();
|
||||||
|
countdownTimer = setInterval(updateCountdowns, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// Detail Panel helpers
|
// Detail Panel helpers
|
||||||
// ========================================
|
// ========================================
|
||||||
@@ -111,6 +155,7 @@
|
|||||||
// Initialize Kanban drag-and-drop on page load
|
// Initialize Kanban drag-and-drop on page load
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
initKanban();
|
initKanban();
|
||||||
|
initCountdowns();
|
||||||
|
|
||||||
// "New Task" header button — scroll to and focus the create-task input in the Pending column
|
// "New Task" header button — scroll to and focus the create-task input in the Pending column
|
||||||
var newTaskBtn = document.getElementById('new-task-trigger');
|
var newTaskBtn = document.getElementById('new-task-trigger');
|
||||||
@@ -132,6 +177,11 @@
|
|||||||
if (target && (target.id === 'kanban-board' || target.id === 'board-content' || (target.closest && target.closest('#kanban-board')))) {
|
if (target && (target.id === 'kanban-board' || target.id === 'board-content' || (target.closest && target.closest('#kanban-board')))) {
|
||||||
initKanban();
|
initKanban();
|
||||||
}
|
}
|
||||||
|
if (target && (target.id === 'board-content' ||
|
||||||
|
(target.matches && target.matches('.upcoming-tasks')) ||
|
||||||
|
(target.querySelector && target.querySelector('.upcoming-tasks')))) {
|
||||||
|
initCountdowns();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Also handle htmx:afterSettle for cases where the DOM isn't fully ready on afterSwap
|
// Also handle htmx:afterSettle for cases where the DOM isn't fully ready on afterSwap
|
||||||
|
|||||||
Reference in New Issue
Block a user