From 3c325bdb0f53057e075bfcccfb2f8477bc40341a Mon Sep 17 00:00:00 2001 From: Yuyao Huang Date: Fri, 8 May 2026 17:42:42 +0800 Subject: [PATCH] feat(notes): add notes feature with CRUD operations and UI - Implement notes database schema and API endpoints - Add notes page with filtering, search, and markdown support - Persist selected goal and task preferences for better UX - Include responsive design and mobile-friendly layout --- app.py | 95 +++++++++++++++- database.py | 82 ++++++++++++++ schema.py | 35 +++++- static/css/notes.css | 257 +++++++++++++++++++++++++++++++++++++++++++ static/js/notes.js | 242 ++++++++++++++++++++++++++++++++++++++++ static/js/tasks.js | 121 +++++++++++++------- templates/base.html | 1 + templates/notes.html | 83 ++++++++++++++ 8 files changed, 871 insertions(+), 45 deletions(-) create mode 100644 static/css/notes.css create mode 100644 static/js/notes.js create mode 100644 templates/notes.html diff --git a/app.py b/app.py index 330bb60..d0660b5 100644 --- a/app.py +++ b/app.py @@ -48,6 +48,12 @@ def admin_page(): return render_template("admin.html") +@app.route("/notes") +@auth.login_required +def notes_page(): + return render_template("notes.html") + + @app.route("/api/auth/register", methods=["POST"]) def api_register(): data = request.get_json() @@ -99,7 +105,8 @@ def api_login(): "user_id": user["id"], "username": user["username"], "role": user["role"], - "max_goals": user["max_goals"] + "max_goals": user["max_goals"], + "selected_goal_id": user.get("selected_goal_id") } }) @@ -121,10 +128,20 @@ def api_me(): "user_id": user["id"], "username": user["username"], "role": user["role"], - "max_goals": user["max_goals"] + "max_goals": user["max_goals"], + "selected_goal_id": user.get("selected_goal_id") }) +@app.route("/api/user/selected-goal", methods=["PATCH"]) +@auth.login_required +def api_set_selected_goal(): + data = request.get_json() + goal_id = data.get("goal_id") + database.update_user(session["user_id"], selected_goal_id=goal_id) + return jsonify({"success": True, "selected_goal_id": goal_id}) + + @app.route("/api/goals", methods=["GET"]) @auth.login_required def api_get_goals(): @@ -206,6 +223,19 @@ def api_toggle_goal(goal_id): return jsonify({"success": True, "activated": new_status}) +@app.route("/api/goals//selected-task", methods=["PATCH"]) +@auth.login_required +def api_set_selected_task(goal_id): + goal = database.get_goal_by_id(goal_id) + if not goal or goal["user_id"] != session["user_id"]: + return jsonify({"success": False, "message": "Goal not found"}), 404 + + data = request.get_json() + task_id = data.get("task_id") + database.update_goal(goal_id, selected_task_id=task_id) + return jsonify({"success": True, "selected_task_id": task_id}) + + @app.route("/api/tasks", methods=["GET"]) @auth.login_required def api_get_tasks(): @@ -352,6 +382,67 @@ def api_update_task_order(task_id): return jsonify({"success": True}) +@app.route("/api/notes", methods=["GET"]) +@auth.login_required +def api_get_notes(): + goal_id = request.args.get("goal_id", type=int) + search = request.args.get("search", "").strip() or None + notes = database.get_notes(session["user_id"], goal_id, search) + return jsonify(notes) + + +@app.route("/api/notes", methods=["POST"]) +@auth.login_required +def api_create_note(): + data = request.get_json() + title = data.get("title", "").strip() + if not title: + return jsonify({"success": False, "message": "Title is required"}), 400 + + goal_id = data.get("goal_id") + task_id = data.get("task_id") + content = data.get("content", "") + + note = database.create_note(session["user_id"], goal_id, task_id, title, content) + return jsonify(note), 201 + + +@app.route("/api/notes/", methods=["GET"]) +@auth.login_required +def api_get_note(note_id): + note = database.get_note_by_id(note_id) + if not note or note["user_id"] != session["user_id"]: + return jsonify({"success": False, "message": "Note not found"}), 404 + return jsonify(note) + + +@app.route("/api/notes/", methods=["PUT"]) +@auth.login_required +def api_update_note(note_id): + note = database.get_note_by_id(note_id) + if not note or note["user_id"] != session["user_id"]: + return jsonify({"success": False, "message": "Note not found"}), 404 + + data = request.get_json() + title = data.get("title", "").strip() + if not title: + return jsonify({"success": False, "message": "Title is required"}), 400 + + updated = database.update_note(note_id, title, data.get("content", "")) + return jsonify(updated) + + +@app.route("/api/notes/", methods=["DELETE"]) +@auth.login_required +def api_delete_note(note_id): + note = database.get_note_by_id(note_id) + if not note or note["user_id"] != session["user_id"]: + return jsonify({"success": False, "message": "Note not found"}), 404 + + database.delete_note(note_id) + return jsonify({"success": True}) + + @app.route("/api/admin/users", methods=["GET"]) @auth.admin_required def api_get_users(): diff --git a/database.py b/database.py index 7221600..9e3512e 100644 --- a/database.py +++ b/database.py @@ -216,3 +216,85 @@ def get_tasks_sorted(goal_id): return finished + unfinished finally: conn.close() + + +def get_notes(user_id, goal_id=None, search=None): + conn = get_connection() + try: + conditions = ["n.user_id = ?"] + params = [user_id] + + if goal_id: + conditions.append("n.goal_id = ?") + params.append(goal_id) + + if search: + conditions.append("(n.title LIKE ? OR n.content LIKE ?)") + like = f"%{search}%" + params.extend([like, like]) + + sql = f"""SELECT n.*, g.title as goal_title, t.title as task_title + FROM notes n + LEFT JOIN goals g ON n.goal_id = g.id + LEFT JOIN tasks t ON n.task_id = t.id + WHERE {' AND '.join(conditions)} + ORDER BY n.updated_at DESC""" + cur = conn.execute(sql, params) + return [row_to_dict(r) for r in cur.fetchall()] + finally: + conn.close() + + +def get_note_by_id(note_id): + conn = get_connection() + try: + cur = conn.execute( + """SELECT n.*, g.title as goal_title, t.title as task_title + FROM notes n + LEFT JOIN goals g ON n.goal_id = g.id + LEFT JOIN tasks t ON n.task_id = t.id + WHERE n.id = ?""", + (note_id,) + ) + return row_to_dict(cur.fetchone()) + finally: + conn.close() + + +def create_note(user_id, goal_id, task_id, title, content): + from datetime import datetime + now = datetime.now().isoformat() + conn = get_connection() + try: + cur = conn.execute( + "INSERT INTO notes (user_id, goal_id, task_id, title, content, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + (user_id, goal_id, task_id, title, content, now, now) + ) + conn.commit() + return get_note_by_id(cur.lastrowid) + finally: + conn.close() + + +def update_note(note_id, title, content): + from datetime import datetime + now = datetime.now().isoformat() + conn = get_connection() + try: + conn.execute( + "UPDATE notes SET title = ?, content = ?, updated_at = ? WHERE id = ?", + (title, content, now, note_id) + ) + conn.commit() + return get_note_by_id(note_id) + finally: + conn.close() + + +def delete_note(note_id): + conn = get_connection() + try: + conn.execute("DELETE FROM notes WHERE id = ?", (note_id,)) + conn.commit() + finally: + conn.close() diff --git a/schema.py b/schema.py index a974fe4..8a2d912 100644 --- a/schema.py +++ b/schema.py @@ -9,7 +9,8 @@ CREATE TABLE IF NOT EXISTS users ( username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'user', - max_goals INTEGER NOT NULL DEFAULT 5 + max_goals INTEGER NOT NULL DEFAULT 5, + selected_goal_id INTEGER ) """ @@ -19,7 +20,9 @@ CREATE TABLE IF NOT EXISTS goals ( user_id INTEGER NOT NULL, title TEXT NOT NULL, activated INTEGER NOT NULL DEFAULT 1, - FOREIGN KEY (user_id) REFERENCES users(id) + selected_task_id INTEGER, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (selected_task_id) REFERENCES tasks(id) ) """ @@ -37,6 +40,22 @@ CREATE TABLE IF NOT EXISTS tasks ( ) """ +CREATE_NOTES = """ +CREATE TABLE IF NOT EXISTS notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + goal_id INTEGER, + task_id INTEGER, + title TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (goal_id) REFERENCES goals(id) ON DELETE CASCADE, + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE +) +""" + def get_connection(): os.makedirs(os.path.dirname(config.DB_PATH), exist_ok=True) @@ -52,6 +71,18 @@ def init_db(): conn.execute(CREATE_USERS) conn.execute(CREATE_GOALS) conn.execute(CREATE_TASKS) + conn.execute(CREATE_NOTES) + + try: + conn.execute("ALTER TABLE goals ADD COLUMN selected_task_id INTEGER") + except sqlite3.OperationalError: + pass + + try: + conn.execute("ALTER TABLE users ADD COLUMN selected_goal_id INTEGER") + except sqlite3.OperationalError: + pass + conn.commit() import bcrypt diff --git a/static/css/notes.css b/static/css/notes.css new file mode 100644 index 0000000..eea826d --- /dev/null +++ b/static/css/notes.css @@ -0,0 +1,257 @@ +.notes-page { + padding: 1rem; +} + +.notes-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + flex-wrap: wrap; + gap: 1rem; +} + +.notes-header h1 { + color: #2c3e50; +} + +.notes-filters { + display: flex; + gap: 1rem; + margin-bottom: 1.5rem; + align-items: center; + flex-wrap: wrap; +} + +.filter-group { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.filter-group label { + font-weight: 500; + color: #555; + white-space: nowrap; +} + +.filter-group select { + padding: 0.5rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; + min-width: 180px; +} + +.search-group { + flex: 1; + min-width: 200px; +} + +.search-group input { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; +} + +.search-group input:focus { + outline: none; + border-color: #667eea; +} + +.notes-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.note-card { + background: white; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + padding: 1rem 1.25rem; + cursor: pointer; + transition: box-shadow 0.15s, transform 0.15s; +} + +.note-card:hover { + box-shadow: 0 3px 8px rgba(0,0,0,0.15); + transform: translateY(-1px); +} + +.note-card-title { + font-weight: 600; + font-size: 1.05rem; + color: #2c3e50; + margin-bottom: 0.25rem; +} + +.note-card-meta { + display: flex; + gap: 1rem; + font-size: 0.8rem; + color: #7f8c8d; + flex-wrap: wrap; +} + +.note-card-meta span { + display: inline-flex; + align-items: center; + gap: 0.25rem; +} + +.note-card-snippet { + margin-top: 0.5rem; + font-size: 0.9rem; + color: #555; + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.note-card-link { + padding: 1px 6px; + border-radius: 3px; + font-size: 0.75rem; + background: #eef; + color: #667eea; +} + +.note-modal-content { + max-width: 800px; +} + +.form-row { + display: flex; + gap: 1rem; +} + +.form-row .form-group { + flex: 1; +} + +.note-editor { + display: flex; + flex-direction: column; +} + +.editor-panes { + display: flex; + gap: 1rem; + min-height: 300px; +} + +.editor-pane { + flex: 1; + display: flex; + flex-direction: column; +} + +.editor-pane textarea { + flex: 1; + resize: vertical; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + font-size: 0.9rem; + line-height: 1.5; +} + +.preview-pane { + flex: 1; + display: flex; + flex-direction: column; + overflow-y: auto; +} + +.markdown-preview { + flex: 1; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + background: #fafafa; + font-size: 0.9rem; + line-height: 1.6; + overflow-y: auto; + min-height: 200px; +} + +.markdown-preview h1, +.markdown-preview h2, +.markdown-preview h3 { + margin: 0.75rem 0 0.5rem; + color: #2c3e50; +} + +.markdown-preview h1 { font-size: 1.3rem; } +.markdown-preview h2 { font-size: 1.15rem; } +.markdown-preview h3 { font-size: 1.05rem; } + +.markdown-preview p { + margin-bottom: 0.5rem; +} + +.markdown-preview code { + background: #eee; + padding: 1px 4px; + border-radius: 3px; + font-size: 0.85rem; +} + +.markdown-preview pre { + background: #282c34; + color: #abb2bf; + padding: 0.75rem; + border-radius: 4px; + overflow-x: auto; + margin: 0.5rem 0; +} + +.markdown-preview pre code { + background: none; + padding: 0; + color: inherit; +} + +.markdown-preview ul, +.markdown-preview ol { + padding-left: 1.5rem; + margin-bottom: 0.5rem; +} + +.markdown-preview blockquote { + border-left: 3px solid #667eea; + padding-left: 0.75rem; + color: #666; + margin: 0.5rem 0; +} + +.markdown-preview table { + border-collapse: collapse; + margin: 0.5rem 0; +} + +.markdown-preview th, +.markdown-preview td { + border: 1px solid #ddd; + padding: 0.4rem 0.75rem; + text-align: left; +} + +.markdown-preview th { + background: #f0f0f0; + font-weight: 600; +} + +@media (max-width: 768px) { + .editor-panes { + flex-direction: column; + } + + .form-row { + flex-direction: column; + gap: 0; + } +} diff --git a/static/js/notes.js b/static/js/notes.js new file mode 100644 index 0000000..7f3be3d --- /dev/null +++ b/static/js/notes.js @@ -0,0 +1,242 @@ +let notes = []; +let goals = []; +let editingNoteId = null; +let filterTimer = null; +let currentUser = null; +let savedGoalId = null; + +async function loadNotes() { + const goalId = document.getElementById("filter-goal").value; + const search = document.getElementById("filter-search").value.trim(); + + let params = new URLSearchParams(); + if (goalId) params.set("goal_id", goalId); + if (search) params.set("search", search); + + try { + notes = await get(`/api/notes?${params.toString()}`); + renderNotes(); + } catch (error) { + console.error("Failed to load notes:", error); + } +} + +async function loadGoals() { + try { + goals = await get("/api/goals"); + currentUser = await get("/api/auth/me"); + savedGoalId = currentUser.selected_goal_id; + populateGoalSelectors(); + } catch (error) { + console.error("Failed to load goals:", error); + } +} + +function populateGoalSelectors() { + const filterSelect = document.getElementById("filter-goal"); + const modalSelect = document.getElementById("note-goal"); + + const activated = goals.filter(g => g.activated); + const options = activated.map(g => + `` + ).join(""); + + const savedGoalExists = savedGoalId && activated.some(g => g.id === savedGoalId); + + filterSelect.innerHTML = `` + options; + if (savedGoalExists) { + filterSelect.value = savedGoalId; + } + + modalSelect.innerHTML = `` + options; +} + +function renderNotes() { + const container = document.getElementById("notes-list"); + + if (notes.length === 0) { + container.innerHTML = ` +
+

No notes yet

+

Create your first note to get started!

+
+ `; + return; + } + + container.innerHTML = notes.map(note => { + const snippet = (note.content || "").replace(/[#*`\[\]()>|~_-]/g, "").substring(0, 120); + const time = formatTime(note.updated_at); + let link = ""; + if (note.task_title) { + link = `${escapeHtml(note.goal_title)} / ${escapeHtml(note.task_title)}`; + } else if (note.goal_title) { + link = `${escapeHtml(note.goal_title)}`; + } + + return ` +
+
${escapeHtml(note.title)}
+
+ ${time} + ${link} +
+
${escapeHtml(snippet)}
+
+ `; + }).join(""); +} + +async function openNote(noteId) { + editingNoteId = noteId; + const note = notes.find(n => n.id === noteId); + if (!note) return; + + document.getElementById("note-modal-title").textContent = "Edit Note"; + document.getElementById("note-id").value = note.id; + document.getElementById("note-title").value = note.title; + document.getElementById("note-content").value = note.content || ""; + document.getElementById("note-goal").value = note.goal_id || ""; + document.getElementById("note-error").textContent = ""; + + await populateTasks(note.goal_id); + document.getElementById("note-task").value = note.task_id || ""; + + document.getElementById("delete-note-btn").style.display = "inline-block"; + updatePreview(); + document.getElementById("note-modal").classList.add("active"); +} + +function openNewNote() { + editingNoteId = null; + document.getElementById("note-modal-title").textContent = "New Note"; + document.getElementById("note-id").value = ""; + document.getElementById("note-title").value = ""; + document.getElementById("note-content").value = ""; + document.getElementById("note-goal").value = savedGoalId || ""; + document.getElementById("note-error").textContent = ""; + document.getElementById("delete-note-btn").style.display = "none"; + updatePreview(); + document.getElementById("note-modal").classList.add("active"); + + if (savedGoalId) { + populateTasks(savedGoalId); + } else { + document.getElementById("note-task").innerHTML = ''; + } +} + +function closeNoteModal() { + document.getElementById("note-modal").classList.remove("active"); + editingNoteId = null; +} + +async function populateTasks(goalId) { + const select = document.getElementById("note-task"); + select.innerHTML = ''; + if (!goalId) return; + + try { + const tasks = await get(`/api/tasks?goal_id=${goalId}`); + select.innerHTML += tasks.map(t => + `` + ).join(""); + } catch (error) { + console.error("Failed to load tasks:", error); + } +} + +function updatePreview() { + const content = document.getElementById("note-content").value; + const preview = document.getElementById("note-preview"); + try { + preview.innerHTML = marked.parse(content || ""); + } catch (e) { + preview.innerHTML = escapeHtml(content || ""); + } +} + +async function handleNoteSubmit(event) { + event.preventDefault(); + const error = document.getElementById("note-error"); + error.textContent = ""; + + const title = document.getElementById("note-title").value.trim(); + if (!title) { + error.textContent = "Title is required"; + return; + } + + const goalId = parseInt(document.getElementById("note-goal").value) || null; + const taskId = parseInt(document.getElementById("note-task").value) || null; + const content = document.getElementById("note-content").value; + + try { + if (editingNoteId) { + await put(`/api/notes/${editingNoteId}`, { title, content }); + } else { + await post("/api/notes", { goal_id: goalId, task_id: taskId, title, content }); + } + closeNoteModal(); + await loadNotes(); + } catch (err) { + error.textContent = err.message; + } +} + +async function deleteNote() { + if (!editingNoteId) return; + if (!confirm("Delete this note?")) return; + + try { + await del(`/api/notes/${editingNoteId}`); + closeNoteModal(); + await loadNotes(); + } catch (error) { + console.error("Failed to delete note:", error); + } +} + +function formatTime(isoString) { + if (!isoString) return ""; + const date = new Date(isoString); + return date.toLocaleString(); +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +document.addEventListener("DOMContentLoaded", () => { + document.getElementById("create-note-btn").addEventListener("click", openNewNote); + document.getElementById("note-modal-close").addEventListener("click", closeNoteModal); + document.getElementById("note-modal-cancel").addEventListener("click", closeNoteModal); + document.getElementById("note-form").addEventListener("submit", handleNoteSubmit); + document.getElementById("delete-note-btn").addEventListener("click", deleteNote); + + document.getElementById("note-content").addEventListener("input", updatePreview); + + document.getElementById("note-goal").addEventListener("change", async (e) => { + const goalId = parseInt(e.target.value) || null; + savedGoalId = goalId; + await patch("/api/user/selected-goal", { goal_id: goalId }); + populateTasks(goalId); + }); + + document.getElementById("filter-goal").addEventListener("change", async () => { + const goalId = parseInt(document.getElementById("filter-goal").value) || null; + savedGoalId = goalId; + await patch("/api/user/selected-goal", { goal_id: goalId }); + loadNotes(); + }); + + document.getElementById("filter-search").addEventListener("input", () => { + clearTimeout(filterTimer); + filterTimer = setTimeout(loadNotes, 300); + }); + + loadGoals(); + loadNotes(); +}); diff --git a/static/js/tasks.js b/static/js/tasks.js index 6341c9d..ae9f958 100644 --- a/static/js/tasks.js +++ b/static/js/tasks.js @@ -3,22 +3,32 @@ 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 => + + selector.innerHTML = activatedGoals.map(goal => `` ).join(""); - - if (activatedGoals.length > 0) { + + 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(); } + + await loadTasks(); } catch (error) { console.error("Failed to load goals:", error); } @@ -34,23 +44,47 @@ async function loadTasks() { initSortable(); initScrollFocus(); - const doingTask = tasks.find(t => t.status === "doing"); - if (doingTask) { - scrollToTask(doingTask.id); + 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(doingTask.id); + 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); } - } 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 = `
@@ -71,11 +105,11 @@ function renderTasks() { function initSortable() { const container = document.getElementById("tasks-list"); - + if (sortableInstance) { sortableInstance.destroy(); } - + sortableInstance = Sortable.create(container, { animation: 150, ghostClass: "sortable-ghost", @@ -84,15 +118,15 @@ function initSortable() { 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(); @@ -117,10 +151,10 @@ function scrollToTask(taskId) { function initScrollFocus() { const scrollView = document.getElementById("scroll-view"); - + scrollView.removeEventListener("scroll", handleScrollFocus); scrollView.addEventListener("scroll", handleScrollFocus); - + handleScrollFocus(); } @@ -150,8 +184,12 @@ function handleScrollFocus() { if (closestItem) { closestItem.classList.add("in-focus"); + const taskId = parseInt(closestItem.dataset.taskId); + + clearTimeout(persistTimer); + persistTimer = setTimeout(() => persistSelectedTask(taskId), 400); + if (isLandscapeMode()) { - const taskId = parseInt(closestItem.dataset.taskId); if (taskId !== selectedTaskId) { selectTask(taskId); } @@ -188,27 +226,27 @@ function closeSidePanel() { 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; @@ -217,9 +255,9 @@ async function saveTask() { 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(); @@ -246,21 +284,21 @@ 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(); @@ -284,16 +322,16 @@ function formatTime(isoString) { 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" @@ -302,20 +340,21 @@ function initWheelScroll() { } document.addEventListener("DOMContentLoaded", () => { - document.getElementById("goal-selector").addEventListener("change", (e) => { + 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(); }); diff --git a/templates/base.html b/templates/base.html index f8d4985..d3f9aa6 100644 --- a/templates/base.html +++ b/templates/base.html @@ -13,6 +13,7 @@