- Add novalidate to task-form to prevent browser validation quirks - Add explicit click handler for Save button that triggers form submission via dispatched event for mobile compatibility
364 lines
11 KiB
JavaScript
364 lines
11 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();
|
|
initScrollFocus();
|
|
|
|
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);
|
|
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);
|
|
}
|
|
}
|
|
} 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;
|
|
}
|
|
} 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.addEventListener("scroll", handleScrollFocus);
|
|
|
|
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);
|
|
|
|
clearTimeout(persistTimer);
|
|
persistTimer = setTimeout(() => persistSelectedTask(taskId), 400);
|
|
|
|
if (isLandscapeMode()) {
|
|
if (taskId !== selectedTaskId) {
|
|
selectTask(taskId);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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("task-save-btn").addEventListener("click", (e) => {
|
|
document.getElementById("task-form").dispatchEvent(new Event("submit", { cancelable: true }));
|
|
});
|
|
|
|
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();
|
|
});
|