Yuyao Huang 84181e1ec2 fix: use scrollTop instead of viewport center for in-focus detection
handleScrollFocus now finds the task closest to scrollTop (offset)
instead of closest to the vertical center of the viewport. This
ensures that the saved task matches the user's scroll target.
2026-05-09 15:37:34 +08:00

374 lines
11 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;
}
} 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);
// Initial call to set in-focus class (before binding scroll handler)
handleScrollFocus();
// Bind scroll handlers after initial setup
scrollView.addEventListener("scroll", handleScrollFocus);
scrollView.addEventListener("scroll", handleScrollSave);
}
function handleScrollFocus() {
const scrollView = document.getElementById("scroll-view");
const taskItems = document.querySelectorAll(".task-item");
const scrollTop = scrollView.scrollTop;
let closestItem = null;
let closestDistance = Infinity;
taskItems.forEach(item => {
const distance = Math.abs(item.offsetTop - scrollTop);
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 && !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();
});