scrollTop assignment triggers an async scroll event. When all tasks fit in the viewport, handleScrollFocus recalculates center-aligned task and picks the last one instead of the saved one. Using setTimeout(0) defers handler binding to after the queued event fires.
388 lines
12 KiB
JavaScript
388 lines
12 KiB
JavaScript
let goals = [];
|
|
let tasks = [];
|
|
let selectedGoalId = null;
|
|
let selectedTaskId = null;
|
|
let sortableInstance = null;
|
|
let persistTimer = null;
|
|
let currentUser = null;
|
|
|
|
async function loadGoals() {
|
|
try {
|
|
goals = await get("/api/goals");
|
|
currentUser = await get("/api/auth/me");
|
|
const selector = document.getElementById("goal-selector");
|
|
const activatedGoals = goals.filter(g => g.activated);
|
|
|
|
selector.innerHTML = activatedGoals.map(goal =>
|
|
`<option value="${goal.id}">${escapeHtml(goal.title)}</option>`
|
|
).join("");
|
|
|
|
const savedGoalId = currentUser.selected_goal_id;
|
|
const savedGoalExists = savedGoalId && activatedGoals.some(g => g.id === savedGoalId);
|
|
|
|
if (savedGoalExists) {
|
|
selectedGoalId = savedGoalId;
|
|
selector.value = savedGoalId;
|
|
} else if (activatedGoals.length > 0) {
|
|
selectedGoalId = activatedGoals[0].id;
|
|
selector.value = selectedGoalId;
|
|
}
|
|
|
|
await loadTasks();
|
|
} catch (error) {
|
|
console.error("Failed to load goals:", error);
|
|
}
|
|
}
|
|
|
|
async function loadTasks() {
|
|
if (!selectedGoalId) return;
|
|
|
|
try {
|
|
tasks = await get(`/api/tasks?goal_id=${selectedGoalId}`);
|
|
|
|
renderTasks();
|
|
initSortable();
|
|
|
|
const currentGoal = goals.find(g => g.id === selectedGoalId);
|
|
const savedTaskId = currentGoal ? currentGoal.selected_task_id : null;
|
|
const savedTaskExists = savedTaskId && tasks.some(t => t.id === savedTaskId);
|
|
|
|
let focusTaskId = null;
|
|
|
|
if (savedTaskExists) {
|
|
focusTaskId = savedTaskId;
|
|
scrollToTask(savedTaskId);
|
|
selectedTaskId = savedTaskId;
|
|
if (isLandscapeMode()) {
|
|
selectTask(savedTaskId);
|
|
}
|
|
} else {
|
|
const doingTask = tasks.find(t => t.status === "doing");
|
|
if (doingTask) {
|
|
focusTaskId = doingTask.id;
|
|
scrollToTask(doingTask.id);
|
|
if (isLandscapeMode()) {
|
|
selectTask(doingTask.id);
|
|
}
|
|
} else if (isLandscapeMode() && tasks.length > 0) {
|
|
selectTask(tasks[0].id);
|
|
}
|
|
}
|
|
|
|
// Directly set in-focus on the scrolled task instead of recalculating
|
|
if (focusTaskId) {
|
|
document.querySelectorAll(".task-item.in-focus").forEach(el => el.classList.remove("in-focus"));
|
|
const focusEl = document.querySelector(`[data-task-id="${focusTaskId}"]`);
|
|
if (focusEl) {
|
|
focusEl.classList.add("in-focus");
|
|
}
|
|
}
|
|
|
|
initScrollFocus();
|
|
} catch (error) {
|
|
console.error("Failed to load tasks:", error);
|
|
}
|
|
}
|
|
|
|
async function persistSelectedTask(taskId) {
|
|
if (!selectedGoalId) return;
|
|
try {
|
|
await patch(`/api/goals/${selectedGoalId}/selected-task`, { task_id: taskId });
|
|
const goal = goals.find(g => g.id === selectedGoalId);
|
|
if (goal) {
|
|
goal.selected_task_id = taskId;
|
|
}
|
|
console.log("Saved selected task:", taskId);
|
|
} catch (error) {
|
|
console.error("Failed to persist selected task:", error);
|
|
}
|
|
}
|
|
|
|
function renderTasks() {
|
|
const container = document.getElementById("tasks-list");
|
|
|
|
if (tasks.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="empty-state">
|
|
<h3>No tasks yet</h3>
|
|
<p>Create your first task for this goal!</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = tasks.map(task => `
|
|
<div class="task-item ${task.status}" data-task-id="${task.id}" onclick="selectTask(${task.id})">
|
|
<div class="task-title">${escapeHtml(task.title)}</div>
|
|
<span class="task-status-badge ${task.status}">${task.status}</span>
|
|
</div>
|
|
`).join("");
|
|
}
|
|
|
|
function initSortable() {
|
|
const container = document.getElementById("tasks-list");
|
|
|
|
if (sortableInstance) {
|
|
sortableInstance.destroy();
|
|
}
|
|
|
|
sortableInstance = Sortable.create(container, {
|
|
animation: 150,
|
|
ghostClass: "sortable-ghost",
|
|
chosenClass: "sortable-chosen",
|
|
filter: ".task-item.done",
|
|
onEnd: async function(evt) {
|
|
const taskId = evt.item.dataset.taskId;
|
|
const newIndex = evt.newIndex;
|
|
|
|
const prevTask = tasks[newIndex - 1];
|
|
const nextTask = tasks[newIndex + 1];
|
|
|
|
let prevOrder = prevTask ? prevTask.order : 0;
|
|
let nextOrder = nextTask ? nextTask.order : prevOrder + 2;
|
|
|
|
const newOrder = (prevOrder + nextOrder) / 2;
|
|
|
|
try {
|
|
await patch(`/api/tasks/${taskId}/order`, { order: newOrder });
|
|
await loadTasks();
|
|
} catch (error) {
|
|
console.error("Failed to update order:", error);
|
|
await loadTasks();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function scrollToTask(taskId) {
|
|
const taskElement = document.querySelector(`[data-task-id="${taskId}"]`);
|
|
if (taskElement) {
|
|
const scrollView = document.getElementById("scroll-view");
|
|
const taskTop = taskElement.offsetTop;
|
|
const scrollViewHeight = scrollView.clientHeight;
|
|
const taskHeight = taskElement.offsetHeight;
|
|
scrollView.scrollTop = taskTop - (scrollViewHeight / 2) + (taskHeight / 2);
|
|
}
|
|
}
|
|
|
|
function initScrollFocus() {
|
|
const scrollView = document.getElementById("scroll-view");
|
|
|
|
scrollView.removeEventListener("scroll", handleScrollFocus);
|
|
scrollView.removeEventListener("scroll", handleScrollSave);
|
|
|
|
// Delay binding to skip the async scroll event queued by scrollToTask
|
|
setTimeout(() => {
|
|
scrollView.addEventListener("scroll", handleScrollFocus);
|
|
scrollView.addEventListener("scroll", handleScrollSave);
|
|
}, 0);
|
|
}
|
|
|
|
function handleScrollFocus() {
|
|
const scrollView = document.getElementById("scroll-view");
|
|
const taskItems = document.querySelectorAll(".task-item");
|
|
|
|
const scrollViewRect = scrollView.getBoundingClientRect();
|
|
const focusCenter = scrollViewRect.top + scrollViewRect.height / 2;
|
|
|
|
let closestItem = null;
|
|
let closestDistance = Infinity;
|
|
|
|
taskItems.forEach(item => {
|
|
const itemRect = item.getBoundingClientRect();
|
|
const itemCenter = itemRect.top + itemRect.height / 2;
|
|
const distance = Math.abs(itemCenter - focusCenter);
|
|
|
|
item.classList.remove("in-focus");
|
|
|
|
if (distance < closestDistance) {
|
|
closestDistance = distance;
|
|
closestItem = item;
|
|
}
|
|
});
|
|
|
|
if (closestItem) {
|
|
closestItem.classList.add("in-focus");
|
|
|
|
const taskId = parseInt(closestItem.dataset.taskId);
|
|
|
|
if (isLandscapeMode()) {
|
|
if (taskId !== selectedTaskId) {
|
|
selectTask(taskId);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleScrollSave() {
|
|
const inFocusTask = document.querySelector(".task-item.in-focus");
|
|
|
|
if (inFocusTask) {
|
|
const taskId = parseInt(inFocusTask.dataset.taskId);
|
|
clearTimeout(persistTimer);
|
|
persistTimer = setTimeout(() => persistSelectedTask(taskId), 400);
|
|
}
|
|
}
|
|
|
|
function isLandscapeMode() {
|
|
return window.innerWidth > window.innerHeight && window.innerWidth >= 1024;
|
|
}
|
|
|
|
function selectTask(taskId) {
|
|
selectedTaskId = taskId;
|
|
const task = tasks.find(t => t.id === taskId);
|
|
|
|
if (!task) return;
|
|
|
|
document.getElementById("edit-task-title").value = task.title;
|
|
document.getElementById("edit-task-desc").value = task.desc || "";
|
|
document.getElementById("edit-task-status").value = task.status;
|
|
document.getElementById("side-panel-error").textContent = "";
|
|
|
|
const sidePanel = document.getElementById("side-panel");
|
|
if (!sidePanel.classList.contains("active")) {
|
|
sidePanel.classList.add("active");
|
|
}
|
|
}
|
|
|
|
function closeSidePanel() {
|
|
if (isLandscapeMode()) return;
|
|
document.getElementById("side-panel").classList.remove("active");
|
|
selectedTaskId = null;
|
|
}
|
|
|
|
async function saveTask() {
|
|
if (!selectedTaskId) return;
|
|
|
|
const error = document.getElementById("side-panel-error");
|
|
error.textContent = "";
|
|
|
|
const title = document.getElementById("edit-task-title").value.trim();
|
|
const desc = document.getElementById("edit-task-desc").value;
|
|
const status = document.getElementById("edit-task-status").value;
|
|
|
|
if (!title) {
|
|
error.textContent = "Title is required";
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await put(`/api/tasks/${selectedTaskId}`, { title, desc });
|
|
|
|
const currentTask = tasks.find(t => t.id === selectedTaskId);
|
|
if (status !== currentTask?.status) {
|
|
await patch(`/api/tasks/${selectedTaskId}/status`, { status });
|
|
}
|
|
|
|
await loadTasks();
|
|
} catch (err) {
|
|
error.textContent = err.message;
|
|
}
|
|
}
|
|
|
|
async function deleteTask() {
|
|
if (!selectedTaskId) return;
|
|
|
|
if (!confirm("Are you sure you want to delete this task?")) return;
|
|
|
|
try {
|
|
await del(`/api/tasks/${selectedTaskId}`);
|
|
closeSidePanel();
|
|
await loadTasks();
|
|
} catch (error) {
|
|
console.error("Failed to delete task:", error);
|
|
}
|
|
}
|
|
|
|
function openTaskModal() {
|
|
document.getElementById("task-id").value = "";
|
|
document.getElementById("task-title").value = "";
|
|
document.getElementById("task-desc").value = "";
|
|
document.getElementById("task-error").textContent = "";
|
|
document.getElementById("task-modal-title").textContent = "Create Task";
|
|
document.getElementById("task-modal").classList.add("active");
|
|
}
|
|
|
|
function closeTaskModal() {
|
|
document.getElementById("task-modal").classList.remove("active");
|
|
}
|
|
|
|
async function handleTaskSubmit(event) {
|
|
event.preventDefault();
|
|
const error = document.getElementById("task-error");
|
|
error.textContent = "";
|
|
|
|
const title = document.getElementById("task-title").value.trim();
|
|
const desc = document.getElementById("task-desc").value;
|
|
|
|
if (!title) {
|
|
error.textContent = "Title is required";
|
|
return;
|
|
}
|
|
|
|
const goalId = parseInt(document.getElementById("goal-selector").value);
|
|
if (!goalId) {
|
|
error.textContent = "Please select a goal first";
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await post("/api/tasks", { goal_id: goalId, title, desc });
|
|
closeTaskModal();
|
|
await loadTasks();
|
|
} catch (err) {
|
|
error.textContent = err.message;
|
|
}
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function formatTime(isoString) {
|
|
if (!isoString) return "";
|
|
const date = new Date(isoString);
|
|
return date.toLocaleString();
|
|
}
|
|
|
|
function initWheelScroll() {
|
|
const scrollView = document.getElementById("scroll-view");
|
|
|
|
scrollView.addEventListener("wheel", (e) => {
|
|
e.preventDefault();
|
|
|
|
const taskItem = scrollView.querySelector(".task-item");
|
|
if (!taskItem) return;
|
|
|
|
const taskHeight = taskItem.offsetHeight + 8;
|
|
const direction = e.deltaY > 0 ? 1 : -1;
|
|
|
|
scrollView.scrollBy({
|
|
top: taskHeight * direction,
|
|
behavior: "smooth"
|
|
});
|
|
}, { passive: false });
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
document.getElementById("goal-selector").addEventListener("change", async (e) => {
|
|
selectedGoalId = parseInt(e.target.value);
|
|
await patch("/api/user/selected-goal", { goal_id: selectedGoalId });
|
|
loadTasks();
|
|
});
|
|
|
|
document.getElementById("create-task-btn").addEventListener("click", openTaskModal);
|
|
document.getElementById("task-modal-close").addEventListener("click", closeTaskModal);
|
|
document.getElementById("task-modal-cancel").addEventListener("click", closeTaskModal);
|
|
document.getElementById("task-form").addEventListener("submit", handleTaskSubmit);
|
|
|
|
document.getElementById("side-panel-close").addEventListener("click", closeSidePanel);
|
|
document.getElementById("save-task-btn").addEventListener("click", saveTask);
|
|
document.getElementById("delete-task-btn").addEventListener("click", deleteTask);
|
|
|
|
initWheelScroll();
|
|
loadGoals();
|
|
});
|