handleScrollSave now saves the task with in-focus class (determined by handleScrollFocus as the centered task) rather than recalculating which task is closest to the top. This ensures consistency between what's highlighted and what's saved.
378 lines
12 KiB
JavaScript
378 lines
12 KiB
JavaScript
let goals = [];
|
|
let tasks = [];
|
|
let selectedGoalId = null;
|
|
let selectedTaskId = null;
|
|
let sortableInstance = null;
|
|
let persistTimer = null;
|
|
let currentUser = null;
|
|
let isInitializing = false;
|
|
|
|
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;
|
|
|
|
isInitializing = true;
|
|
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);
|
|
|
|
if (savedTaskExists) {
|
|
scrollToTask(savedTaskId);
|
|
selectedTaskId = savedTaskId;
|
|
if (isLandscapeMode()) {
|
|
selectTask(savedTaskId);
|
|
}
|
|
} else {
|
|
const doingTask = tasks.find(t => t.status === "doing");
|
|
if (doingTask) {
|
|
scrollToTask(doingTask.id);
|
|
if (isLandscapeMode()) {
|
|
selectTask(doingTask.id);
|
|
}
|
|
} else if (isLandscapeMode() && tasks.length > 0) {
|
|
selectTask(tasks[0].id);
|
|
}
|
|
}
|
|
|
|
initScrollFocus();
|
|
} catch (error) {
|
|
console.error("Failed to load tasks:", error);
|
|
} finally {
|
|
isInitializing = false;
|
|
}
|
|
}
|
|
|
|
async function persistSelectedTask(taskId) {
|
|
if (!selectedGoalId || isInitializing) 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;
|
|
scrollView.scrollTop = taskTop;
|
|
}
|
|
}
|
|
|
|
function initScrollFocus() {
|
|
const scrollView = document.getElementById("scroll-view");
|
|
|
|
scrollView.removeEventListener("scroll", handleScrollFocus);
|
|
scrollView.removeEventListener("scroll", handleScrollSave);
|
|
scrollView.addEventListener("scroll", handleScrollFocus);
|
|
scrollView.addEventListener("scroll", handleScrollSave);
|
|
|
|
// Initial call to set in-focus class
|
|
handleScrollFocus();
|
|
}
|
|
|
|
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() {
|
|
// Save the task that has in-focus class (determined by handleScrollFocus)
|
|
const inFocusTask = document.querySelector(".task-item.in-focus");
|
|
console.log("handleScrollSave: inFocusTask=", inFocusTask?.dataset.taskId, "isInitializing=", isInitializing);
|
|
|
|
if (inFocusTask && !isInitializing) {
|
|
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();
|
|
});
|