Compare commits

..
22 Commits
Author SHA1 Message Date
Yuyao Huang 5e827e7d99 fix: refresh side panel after loadTasks in non-landscape mode
After saveTask or setTaskStatus triggers loadTasks(), the side panel
now refreshes from the updated server data. If the selected task no
longer exists (deleted or goal changed), the side panel closes.
This ensures Edit Task always matches selectedTaskId.
2026-05-09 16:37:26 +08:00
Yuyao Huang fcee783ee5 feat: clicking a task now scrolls to and highlights it
selectTask() now also sets in-focus class and calls scrollToTask(),
making click behavior consistent with scroll-to-focus behavior.
Removed selectedTaskId assignment in loadTasks since selectTask
already sets it.
2026-05-09 16:34:04 +08:00
Yuyao Huang 3f0fccd218 feat: disable Save button when content unchanged
- Save button starts disabled and only enables when title or description
  differs from the original task values
- updateSaveButton() compares current input values against task data
- input event listeners on title and desc fields call updateSaveButton
- Add global button:disabled style (opacity 0.5, cursor not-allowed)
2026-05-09 16:26:41 +08:00
Yuyao Huang ea21b0c78c feat: status buttons as one-row group, wider side panel
- Status buttons now form a seamless button group (no gaps, shared borders,
  rounded ends) with flex-wrap: nowrap to keep them in one row
- Side panel width increased from 350px to 400px for more content space
2026-05-09 16:21:36 +08:00
Yuyao Huang 9c1d45506a feat: replace status dropdown with one-click toggle buttons
Replace <select> in side panel with 4 toggle buttons (To Do / Doing /
Pending / Done). Clicking a button immediately sends the PATCH status
API call. Active button is highlighted with status-specific colors and
shadow. saveTask now only handles title/description changes.
2026-05-09 16:16:58 +08:00
Yuyao Huang 43ca6b8462 fix: correct task sort order per status
DONE: finished_time ASC, PENDING: start_time ASC, TODO: order ASC
2026-05-09 16:13:05 +08:00
Yuyao Huang 01ae9c964a fix: add position: relative to scroll-view for correct offsetTop
Without position: relative, .task-item offsetParent is the body element,
causing offsetTop to be measured from document root rather than the
scroll container. This makes scrollToTask calculate wrong scrollTop.
2026-05-09 16:06:06 +08:00
Yuyao Huang 5294446407 fix: use requestAnimationFrame to defer in-focus and handler binding
requestAnimationFrame waits until after the browser has rendered the
current frame, which includes processing the async scroll event queued
by scrollToTask. This ensures in-focus is set after the scroll event
fires, not overwritten by it.
2026-05-09 16:04:08 +08:00
Yuyao Huang ab000bcd41 fix: delay scroll handler binding to skip queued scroll event
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.
2026-05-09 15:59:13 +08:00
Yuyao Huang 2229fdd0ef fix: directly set in-focus on scrolled task instead of recalculating geometry
Previously handleScrollFocus() recalculated the centered task during
init, which could select a different task than the one scrollToTask()
targeted due to scroll container clamping or DOM layout timing.

Now the scrolled task directly receives the in-focus class, and
handleScrollFocus is only used during user-initiated scroll events.
Also removes the isInitializing flag as it's no longer needed.
2026-05-09 15:52:24 +08:00
Yuyao Huang 1a23558cad fix: restore center alignment in scrollToTask to match handleScrollFocus
Both scrollToTask and handleScrollFocus now use center-of-viewport
calculation, ensuring consistent behavior between scrolling and
in-focus detection.
2026-05-09 15:44:47 +08:00
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
Yuyao Huang eca0cf4193 fix: call handleScrollFocus before binding scroll event handlers
Call handleScrollFocus before adding scroll event listeners to prevent
handleScrollSave from triggering during initial setup.
2026-05-09 15:33:40 +08:00
Yuyao Huang 14ebbda585 fix: skip save during initial handleScrollFocus call
Wrap the initial handleScrollFocus call with isInitializing flag
to prevent handleScrollSave from incorrectly updating selected_task_id
2026-05-09 15:29:01 +08:00
Yuyao Huang fd92c6fe96 fix: save the in-focus task instead of recalculating top task
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.
2026-05-09 15:21:13 +08:00
Yuyao Huang 12610d26c0 Add debugging logs to trace scroll save behavior 2026-05-09 15:14:11 +08:00
Yuyao Huang 1df90490e6 fix: restore scroll position and in-focus highlighting correctly
- Set selectedTaskId when loading saved task
- Call handleScrollFocus initially to set in-focus class
- Skip saving during initialization unless task matches saved
2026-05-09 14:51:41 +08:00
Yuyao Huang ca7bd7e24e fix: align task to top instead of center in scroll view 2026-05-09 14:47:03 +08:00
Yuyao Huang 025195be27 Add detailed scroll position diagnostics 2026-05-09 14:43:29 +08:00
Yuyao Huang 0f1fa712a9 Add delayed scrollTop checks to catch post-load changes 2026-05-09 14:39:00 +08:00
Yuyao Huang df74f1b8a7 Add end-of-loadTasks scrollTop debug log 2026-05-09 14:35:28 +08:00
Yuyao Huang 295fde8a75 Add debug logging for goals data 2026-05-09 14:26:57 +08:00
5 changed files with 191 additions and 43 deletions
+13 -7
View File
@@ -202,19 +202,25 @@ def get_tasks_sorted(goal_id):
try: try:
cur = conn.execute( cur = conn.execute(
"""SELECT * FROM tasks WHERE goal_id = ? AND status = 'done' """SELECT * FROM tasks WHERE goal_id = ? AND status = 'done'
ORDER BY finished_time DESC""", ORDER BY finished_time ASC""",
(goal_id,) (goal_id,)
) )
finished = [row_to_dict(r) for r in cur.fetchall()] finished = [row_to_dict(r) for r in cur.fetchall()]
cur = conn.execute( cur = conn.execute(
"""SELECT * FROM tasks WHERE goal_id = ? AND status != 'done' """SELECT * FROM tasks WHERE goal_id = ? AND status != 'done'
ORDER BY CASE status ORDER BY
WHEN 'doing' THEN 1 CASE status
WHEN 'pending' THEN 2 WHEN 'doing' THEN 0
WHEN 'todo' THEN 3 WHEN 'pending' THEN 1
ELSE 4 WHEN 'todo' THEN 2
END, "order" ASC""", END ASC,
CASE status
WHEN 'pending' THEN start_time
END ASC,
CASE status
WHEN 'todo' THEN "order"
END ASC""",
(goal_id,) (goal_id,)
) )
unfinished = [row_to_dict(r) for r in cur.fetchall()] unfinished = [row_to_dict(r) for r in cur.fetchall()]
+5
View File
@@ -182,6 +182,11 @@ body {
background-color: #229954; background-color: #229954;
} }
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error-message { .error-message {
color: #e74c3c; color: #e74c3c;
margin-bottom: 1rem; margin-bottom: 1rem;
+77 -1
View File
@@ -44,6 +44,7 @@
flex: 1; flex: 1;
height: 600px; height: 600px;
overflow-y: auto; overflow-y: auto;
position: relative;
background: white; background: white;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1); box-shadow: 0 2px 4px rgba(0,0,0,0.1);
@@ -96,6 +97,81 @@
border-color: #28a745; border-color: #28a745;
} }
.status-toggle {
display: flex;
gap: 0;
flex-wrap: nowrap;
}
.status-btn {
padding: 0.5rem 1rem;
border: 2px solid #ddd;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.15s ease;
background: #f8f9fa;
color: #666;
margin-left: -1px;
}
.status-btn:first-child {
border-radius: 6px 0 0 6px;
margin-left: 0;
}
.status-btn:last-child {
border-radius: 0 6px 6px 0;
}
.status-btn.todo:hover {
border-color: #667eea;
color: #667eea;
}
.status-btn.todo.active {
background: #f8f9fa;
border-color: #667eea;
color: #667eea;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
}
.status-btn.doing:hover {
border-color: #ffc107;
color: #b8860b;
}
.status-btn.doing.active {
background: #fff3cd;
border-color: #ffc107;
color: #856404;
box-shadow: 0 0 0 2px rgba(255, 193, 7, 0.2);
}
.status-btn.pending:hover {
border-color: #2196F3;
color: #2196F3;
}
.status-btn.pending.active {
background: #e7f3ff;
border-color: #2196F3;
color: #0d6efd;
box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.2);
}
.status-btn.done:hover {
border-color: #28a745;
color: #28a745;
}
.status-btn.done.active {
background: #d4edda;
border-color: #28a745;
color: #155724;
box-shadow: 0 0 0 2px rgba(40, 167, 69, 0.2);
}
.task-item:hover { .task-item:hover {
background: #e9ecef; background: #e9ecef;
} }
@@ -284,7 +360,7 @@
.side-panel { .side-panel {
position: relative; position: relative;
right: 0; right: 0;
width: 350px; width: 400px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1); box-shadow: 0 2px 4px rgba(0,0,0,0.1);
border-radius: 8px; border-radius: 8px;
z-index: 1; z-index: 1;
+88 -27
View File
@@ -5,7 +5,6 @@ let selectedTaskId = null;
let sortableInstance = null; let sortableInstance = null;
let persistTimer = null; let persistTimer = null;
let currentUser = null; let currentUser = null;
let isInitializing = false;
async function loadGoals() { async function loadGoals() {
try { try {
@@ -38,31 +37,28 @@ async function loadGoals() {
async function loadTasks() { async function loadTasks() {
if (!selectedGoalId) return; if (!selectedGoalId) return;
isInitializing = true;
try { try {
tasks = await get(`/api/tasks?goal_id=${selectedGoalId}`); tasks = await get(`/api/tasks?goal_id=${selectedGoalId}`);
console.log("loadTasks: got", tasks.length, "tasks");
console.log("Task order:", tasks.map(t => ({id: t.id, title: t.title, status: t.status})));
renderTasks(); renderTasks();
initSortable(); initSortable();
const currentGoal = goals.find(g => g.id === selectedGoalId); const currentGoal = goals.find(g => g.id === selectedGoalId);
const savedTaskId = currentGoal ? currentGoal.selected_task_id : null; const savedTaskId = currentGoal ? currentGoal.selected_task_id : null;
console.log("loadTasks: savedTaskId from goal =", savedTaskId);
const savedTaskExists = savedTaskId && tasks.some(t => t.id === savedTaskId); const savedTaskExists = savedTaskId && tasks.some(t => t.id === savedTaskId);
console.log("loadTasks: savedTaskExists =", savedTaskExists);
let focusTaskId = null;
if (savedTaskExists) { if (savedTaskExists) {
console.log("loadTasks: using savedTaskId path"); focusTaskId = savedTaskId;
scrollToTask(savedTaskId); scrollToTask(savedTaskId);
if (isLandscapeMode()) { if (isLandscapeMode()) {
selectTask(savedTaskId); selectTask(savedTaskId);
} }
} else { } else {
console.log("loadTasks: using fallback path");
const doingTask = tasks.find(t => t.status === "doing"); const doingTask = tasks.find(t => t.status === "doing");
if (doingTask) { if (doingTask) {
focusTaskId = doingTask.id;
scrollToTask(doingTask.id); scrollToTask(doingTask.id);
if (isLandscapeMode()) { if (isLandscapeMode()) {
selectTask(doingTask.id); selectTask(doingTask.id);
@@ -72,22 +68,46 @@ async function loadTasks() {
} }
} }
requestAnimationFrame(() => {
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");
}
}
// Refresh side panel if a task was selected
if (selectedTaskId) {
const currentTask = tasks.find(t => t.id === selectedTaskId);
if (currentTask) {
document.getElementById("edit-task-title").value = currentTask.title;
document.getElementById("edit-task-desc").value = currentTask.desc || "";
document.querySelectorAll(".status-btn").forEach(btn => {
btn.classList.toggle("active", btn.dataset.status === currentTask.status);
});
updateSaveButton();
} else {
closeSidePanel();
}
}
initScrollFocus(); initScrollFocus();
});
} catch (error) { } catch (error) {
console.error("Failed to load tasks:", error); console.error("Failed to load tasks:", error);
} finally {
isInitializing = false;
} }
} }
async function persistSelectedTask(taskId) { async function persistSelectedTask(taskId) {
if (!selectedGoalId || isInitializing) return; if (!selectedGoalId) return;
try { try {
await patch(`/api/goals/${selectedGoalId}/selected-task`, { task_id: taskId }); await patch(`/api/goals/${selectedGoalId}/selected-task`, { task_id: taskId });
const goal = goals.find(g => g.id === selectedGoalId); const goal = goals.find(g => g.id === selectedGoalId);
if (goal) { if (goal) {
goal.selected_task_id = taskId; goal.selected_task_id = taskId;
} }
console.log("Saved selected task:", taskId);
} catch (error) { } catch (error) {
console.error("Failed to persist selected task:", error); console.error("Failed to persist selected task:", error);
} }
@@ -150,19 +170,13 @@ function initSortable() {
} }
function scrollToTask(taskId) { function scrollToTask(taskId) {
console.log("scrollToTask called:", taskId);
const taskElement = document.querySelector(`[data-task-id="${taskId}"]`); const taskElement = document.querySelector(`[data-task-id="${taskId}"]`);
if (taskElement) { if (taskElement) {
const scrollView = document.getElementById("scroll-view"); const scrollView = document.getElementById("scroll-view");
const taskTop = taskElement.offsetTop; const taskTop = taskElement.offsetTop;
const scrollViewHeight = scrollView.clientHeight; const scrollViewHeight = scrollView.clientHeight;
const taskHeight = taskElement.offsetHeight; const taskHeight = taskElement.offsetHeight;
const targetScrollTop = taskTop - (scrollViewHeight / 2) + (taskHeight / 2); scrollView.scrollTop = taskTop - (scrollViewHeight / 2) + (taskHeight / 2);
console.log("Scrolling to:", targetScrollTop, "element offsetTop:", taskTop);
scrollView.scrollTop = targetScrollTop;
console.log("scrollTop after set:", scrollView.scrollTop);
} else {
console.log("Task element not found for id:", taskId);
} }
} }
@@ -170,7 +184,9 @@ function initScrollFocus() {
const scrollView = document.getElementById("scroll-view"); const scrollView = document.getElementById("scroll-view");
scrollView.removeEventListener("scroll", handleScrollFocus); scrollView.removeEventListener("scroll", handleScrollFocus);
scrollView.removeEventListener("scroll", handleScrollSave);
scrollView.addEventListener("scroll", handleScrollFocus); scrollView.addEventListener("scroll", handleScrollFocus);
scrollView.addEventListener("scroll", handleScrollSave);
} }
function handleScrollFocus() { function handleScrollFocus() {
@@ -201,9 +217,6 @@ function handleScrollFocus() {
const taskId = parseInt(closestItem.dataset.taskId); const taskId = parseInt(closestItem.dataset.taskId);
clearTimeout(persistTimer);
persistTimer = setTimeout(() => persistSelectedTask(taskId), 400);
if (isLandscapeMode()) { if (isLandscapeMode()) {
if (taskId !== selectedTaskId) { if (taskId !== selectedTaskId) {
selectTask(taskId); selectTask(taskId);
@@ -212,6 +225,16 @@ function handleScrollFocus() {
} }
} }
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() { function isLandscapeMode() {
return window.innerWidth > window.innerHeight && window.innerWidth >= 1024; return window.innerWidth > window.innerHeight && window.innerWidth >= 1024;
} }
@@ -222,10 +245,22 @@ function selectTask(taskId) {
if (!task) return; if (!task) return;
document.querySelectorAll(".task-item.in-focus").forEach(el => el.classList.remove("in-focus"));
const taskEl = document.querySelector(`[data-task-id="${taskId}"]`);
if (taskEl) {
taskEl.classList.add("in-focus");
}
scrollToTask(taskId);
document.getElementById("edit-task-title").value = task.title; document.getElementById("edit-task-title").value = task.title;
document.getElementById("edit-task-desc").value = task.desc || ""; document.getElementById("edit-task-desc").value = task.desc || "";
document.getElementById("edit-task-status").value = task.status;
document.getElementById("side-panel-error").textContent = ""; document.getElementById("side-panel-error").textContent = "";
updateSaveButton();
document.querySelectorAll(".status-btn").forEach(btn => {
btn.classList.toggle("active", btn.dataset.status === task.status);
});
const sidePanel = document.getElementById("side-panel"); const sidePanel = document.getElementById("side-panel");
if (!sidePanel.classList.contains("active")) { if (!sidePanel.classList.contains("active")) {
@@ -233,6 +268,17 @@ function selectTask(taskId) {
} }
} }
function updateSaveButton() {
const task = tasks.find(t => t.id === selectedTaskId);
if (!task) return;
const title = document.getElementById("edit-task-title").value.trim();
const desc = document.getElementById("edit-task-desc").value;
const changed = title !== task.title || desc !== (task.desc || "");
document.getElementById("save-task-btn").disabled = !changed;
}
function closeSidePanel() { function closeSidePanel() {
if (isLandscapeMode()) return; if (isLandscapeMode()) return;
document.getElementById("side-panel").classList.remove("active"); document.getElementById("side-panel").classList.remove("active");
@@ -247,7 +293,6 @@ async function saveTask() {
const title = document.getElementById("edit-task-title").value.trim(); const title = document.getElementById("edit-task-title").value.trim();
const desc = document.getElementById("edit-task-desc").value; const desc = document.getElementById("edit-task-desc").value;
const status = document.getElementById("edit-task-status").value;
if (!title) { if (!title) {
error.textContent = "Title is required"; error.textContent = "Title is required";
@@ -256,12 +301,24 @@ async function saveTask() {
try { try {
await put(`/api/tasks/${selectedTaskId}`, { title, desc }); await put(`/api/tasks/${selectedTaskId}`, { title, desc });
await loadTasks();
const currentTask = tasks.find(t => t.id === selectedTaskId); } catch (err) {
if (status !== currentTask?.status) { error.textContent = err.message;
await patch(`/api/tasks/${selectedTaskId}/status`, { status });
} }
}
async function setTaskStatus(status) {
if (!selectedTaskId) return;
const error = document.getElementById("side-panel-error");
error.textContent = "";
document.querySelectorAll(".status-btn").forEach(btn => {
btn.classList.toggle("active", btn.dataset.status === status);
});
try {
await patch(`/api/tasks/${selectedTaskId}/status`, { status });
await loadTasks(); await loadTasks();
} catch (err) { } catch (err) {
error.textContent = err.message; error.textContent = err.message;
@@ -368,8 +425,12 @@ document.addEventListener("DOMContentLoaded", () => {
document.getElementById("side-panel-close").addEventListener("click", closeSidePanel); document.getElementById("side-panel-close").addEventListener("click", closeSidePanel);
document.getElementById("save-task-btn").addEventListener("click", saveTask); document.getElementById("save-task-btn").addEventListener("click", saveTask);
document.getElementById("save-task-btn").disabled = true;
document.getElementById("delete-task-btn").addEventListener("click", deleteTask); document.getElementById("delete-task-btn").addEventListener("click", deleteTask);
document.getElementById("edit-task-title").addEventListener("input", updateSaveButton);
document.getElementById("edit-task-desc").addEventListener("input", updateSaveButton);
initWheelScroll(); initWheelScroll();
loadGoals(); loadGoals();
}); });
+7 -7
View File
@@ -39,13 +39,13 @@
<textarea id="edit-task-desc" rows="12" autocomplete="off"></textarea> <textarea id="edit-task-desc" rows="12" autocomplete="off"></textarea>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="edit-task-status">Status</label> <label>Status</label>
<select id="edit-task-status"> <div class="status-toggle" id="status-toggle">
<option value="todo">To Do</option> <button class="status-btn todo" data-status="todo" onclick="setTaskStatus('todo')">To Do</button>
<option value="doing">Doing</option> <button class="status-btn doing" data-status="doing" onclick="setTaskStatus('doing')">Doing</button>
<option value="pending">Pending</option> <button class="status-btn pending" data-status="pending" onclick="setTaskStatus('pending')">Pending</button>
<option value="done">Done</option> <button class="status-btn done" data-status="done" onclick="setTaskStatus('done')">Done</button>
</select> </div>
</div> </div>
<div id="side-panel-error" class="error-message"></div> <div id="side-panel-error" class="error-message"></div>
<div class="side-panel-actions"> <div class="side-panel-actions">