Initial commit: GoalsBreakDown web app

- Flask backend with TinyDB database
- Multi-user auth with bcrypt password hashing
- Goal CRUD with activation/deactivation and per-user limits
- Task CRUD with status tracking (todo/doing/pending/done)
- Focus rule: one doing task per goal
- Time picker-style scroll view with drag-and-drop reordering
- Admin panel for user management
- uv environment management
This commit is contained in:
Yuyao Huang
2026-05-08 12:41:19 +08:00
commit f3bffa40cd
20 changed files with 2374 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
.goals-page {
padding: 1rem;
}
.goals-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.goals-header h1 {
color: #2c3e50;
}
.goals-list {
display: grid;
gap: 1rem;
}
.goal-card {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
display: flex;
justify-content: space-between;
align-items: center;
transition: transform 0.2s, box-shadow 0.2s;
}
.goal-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.goal-info {
flex: 1;
}
.goal-title {
font-size: 1.2rem;
font-weight: 600;
color: #2c3e50;
margin-bottom: 0.5rem;
}
.goal-status {
font-size: 0.9rem;
color: #7f8c8d;
}
.goal-status.active {
color: #27ae60;
}
.goal-status.inactive {
color: #e74c3c;
}
.goal-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.toggle-btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: background-color 0.2s;
}
.toggle-btn.activate {
background-color: #27ae60;
color: white;
}
.toggle-btn.activate:hover {
background-color: #229954;
}
.toggle-btn.deactivate {
background-color: #f39c12;
color: white;
}
.toggle-btn.deactivate:hover {
background-color: #e67e22;
}
.edit-btn {
padding: 0.5rem 1rem;
background-color: #3498db;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
.edit-btn:hover {
background-color: #2980b9;
}
.delete-btn {
padding: 0.5rem 1rem;
background-color: #e74c3c;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
.delete-btn:hover {
background-color: #c0392b;
}
.empty-state {
text-align: center;
padding: 3rem;
color: #7f8c8d;
}
.empty-state h3 {
margin-bottom: 1rem;
}
+253
View File
@@ -0,0 +1,253 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background-color: #f5f5f5;
color: #333;
line-height: 1.6;
}
.navbar {
background-color: #2c3e50;
color: white;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.nav-brand {
font-size: 1.5rem;
font-weight: bold;
}
.nav-links {
display: flex;
align-items: center;
gap: 1rem;
}
.nav-link {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: background-color 0.2s;
}
.nav-link:hover {
background-color: #34495e;
}
.nav-user {
color: #bdc3c7;
font-size: 0.9rem;
}
.nav-btn {
background-color: #e74c3c;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
.nav-btn:hover {
background-color: #c0392b;
}
.container {
max-width: 1200px;
margin: 2rem auto;
padding: 0 1rem;
}
.auth-page {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.auth-container {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
width: 100%;
max-width: 400px;
}
.auth-container h1 {
text-align: center;
margin-bottom: 1.5rem;
color: #2c3e50;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #555;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
transition: border-color 0.2s;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: #667eea;
}
.btn-primary {
width: 100%;
padding: 0.75rem;
background-color: #667eea;
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-primary:hover {
background-color: #5568d3;
}
.btn-secondary {
padding: 0.5rem 1rem;
background-color: #95a5a6;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-secondary:hover {
background-color: #7f8c8d;
}
.btn-danger {
padding: 0.5rem 1rem;
background-color: #e74c3c;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-danger:hover {
background-color: #c0392b;
}
.btn-success {
padding: 0.5rem 1rem;
background-color: #27ae60;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-success:hover {
background-color: #229954;
}
.error-message {
color: #e74c3c;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.auth-link {
text-align: center;
margin-top: 1rem;
color: #666;
}
.auth-link a {
color: #667eea;
text-decoration: none;
}
.auth-link a:hover {
text-decoration: underline;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal.active {
display: flex;
}
.modal-content {
background: white;
padding: 2rem;
border-radius: 8px;
width: 100%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.modal-header h2 {
color: #2c3e50;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #666;
}
.modal-actions {
display: flex;
gap: 1rem;
margin-top: 1rem;
}
+266
View File
@@ -0,0 +1,266 @@
.tasks-page {
padding: 1rem;
}
.tasks-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
flex-wrap: wrap;
gap: 1rem;
}
.tasks-header h1 {
color: #2c3e50;
}
.goal-selector-container {
display: flex;
align-items: center;
gap: 0.5rem;
}
.goal-selector-container label {
font-weight: 500;
color: #555;
}
.goal-selector-container select {
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
min-width: 200px;
}
.tasks-container {
display: flex;
gap: 2rem;
margin-bottom: 2rem;
}
.scroll-view {
flex: 1;
height: 300px;
overflow-y: auto;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.tasks-list {
padding: 120px 1rem;
}
.task-item {
padding: 0.75rem 1rem;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
border: 2px solid transparent;
background: #f8f9fa;
min-height: 60px;
margin-bottom: 0.5rem;
opacity: 0.5;
transform: scale(0.95);
}
.task-item:last-child {
margin-bottom: 0;
}
.task-item.in-focus {
opacity: 1;
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.task-item.in-focus.doing {
background: #fff3cd;
border-color: #ffc107;
}
.task-item.in-focus.todo {
background: #f8f9fa;
border-color: #667eea;
}
.task-item.in-focus.pending {
background: #e7f3ff;
border-color: #2196F3;
}
.task-item.in-focus.done {
background: #d4edda;
border-color: #28a745;
}
.task-item:hover {
background: #e9ecef;
}
.task-item.doing {
background: #fff3cd;
border-color: #ffc107;
transform: scale(1.02);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.task-item.todo {
background: #f8f9fa;
}
.task-item.pending {
background: #e7f3ff;
border-color: #2196F3;
}
.task-item.done {
background: #d4edda;
border-color: #28a745;
opacity: 0.7;
}
.task-title {
font-weight: 600;
margin-bottom: 0.25rem;
}
.task-status-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
}
.task-status-badge.todo {
background: #6c757d;
color: white;
}
.task-status-badge.doing {
background: #ffc107;
color: #333;
}
.task-status-badge.pending {
background: #2196F3;
color: white;
}
.task-status-badge.done {
background: #28a745;
color: white;
}
.task-item.sortable-ghost {
opacity: 0.4;
}
.task-item.sortable-chosen {
box-shadow: 0 8px 16px rgba(0,0,0,0.2);
opacity: 1;
}
.finished-section {
flex: 0 0 300px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 1rem;
max-height: 600px;
overflow-y: auto;
}
.finished-section h2 {
color: #2c3e50;
margin-bottom: 1rem;
font-size: 1.2rem;
}
.finished-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.finished-item {
padding: 0.75rem;
background: #f8f9fa;
border-radius: 4px;
border-left: 3px solid #28a745;
}
.finished-item-title {
font-weight: 500;
margin-bottom: 0.25rem;
}
.finished-item-time {
font-size: 0.8rem;
color: #6c757d;
}
.side-panel {
position: fixed;
top: 0;
right: -400px;
width: 400px;
height: 100%;
background: white;
box-shadow: -2px 0 8px rgba(0,0,0,0.1);
transition: right 0.3s ease;
z-index: 1000;
display: flex;
flex-direction: column;
}
.side-panel.active {
right: 0;
}
.side-panel-header {
padding: 1rem;
border-bottom: 1px solid #ddd;
display: flex;
justify-content: space-between;
align-items: center;
}
.side-panel-header h2 {
color: #2c3e50;
font-size: 1.2rem;
}
.side-panel-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #666;
}
.side-panel-content {
padding: 1rem;
flex: 1;
overflow-y: auto;
}
.side-panel-actions {
display: flex;
gap: 1rem;
margin-top: 1rem;
}
#create-task-btn {
margin-top: 1rem;
}
.empty-state {
text-align: center;
padding: 3rem;
color: #7f8c8d;
}
+45
View File
@@ -0,0 +1,45 @@
const API_BASE = "";
async function apiRequest(endpoint, options = {}) {
const url = API_BASE + endpoint;
const config = {
headers: {
"Content-Type": "application/json",
},
credentials: "same-origin",
...options,
};
if (options.body && typeof options.body === "object") {
config.body = JSON.stringify(options.body);
}
const response = await fetch(url, config);
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || "Request failed");
}
return data;
}
async function get(endpoint) {
return apiRequest(endpoint, { method: "GET" });
}
async function post(endpoint, body) {
return apiRequest(endpoint, { method: "POST", body });
}
async function put(endpoint, body) {
return apiRequest(endpoint, { method: "PUT", body });
}
async function del(endpoint) {
return apiRequest(endpoint, { method: "DELETE" });
}
async function patch(endpoint, body) {
return apiRequest(endpoint, { method: "PATCH", body });
}
+92
View File
@@ -0,0 +1,92 @@
async function checkAuth() {
try {
const user = await get("/api/auth/me");
const userInfo = document.getElementById("user-info");
if (userInfo) {
userInfo.textContent = user.username;
}
const adminLink = document.getElementById("admin-link");
if (adminLink && user.role === "admin") {
adminLink.style.display = "inline";
}
return user;
} catch (error) {
window.location.href = "/login";
return null;
}
}
async function handleLogin(event) {
event.preventDefault();
const form = event.target;
const errorMessage = document.getElementById("error-message");
errorMessage.textContent = "";
const username = form.username.value.trim();
const password = form.password.value;
try {
await post("/api/auth/login", { username, password });
window.location.href = "/goals";
} catch (error) {
errorMessage.textContent = error.message;
}
}
async function handleRegister(event) {
event.preventDefault();
const form = event.target;
const errorMessage = document.getElementById("error-message");
errorMessage.textContent = "";
const username = form.username.value.trim();
const password = form.password.value;
const confirmPassword = form["confirm-password"].value;
if (password !== confirmPassword) {
errorMessage.textContent = "Passwords do not match";
return;
}
if (password.length < 6) {
errorMessage.textContent = "Password must be at least 6 characters";
return;
}
try {
await post("/api/auth/register", { username, password });
window.location.href = "/goals";
} catch (error) {
errorMessage.textContent = error.message;
}
}
async function handleLogout() {
try {
await post("/api/auth/logout");
window.location.href = "/login";
} catch (error) {
console.error("Logout failed:", error);
}
}
document.addEventListener("DOMContentLoaded", () => {
const loginForm = document.getElementById("login-form");
if (loginForm) {
loginForm.addEventListener("submit", handleLogin);
}
const registerForm = document.getElementById("register-form");
if (registerForm) {
registerForm.addEventListener("submit", handleRegister);
}
const logoutBtn = document.getElementById("logout-btn");
if (logoutBtn) {
logoutBtn.addEventListener("click", handleLogout);
}
if (!document.body.classList.contains("auth-page")) {
checkAuth();
}
});
+152
View File
@@ -0,0 +1,152 @@
let goals = [];
let deleteGoalId = null;
async function loadGoals() {
try {
goals = await get("/api/goals");
renderGoals();
} catch (error) {
console.error("Failed to load goals:", error);
}
}
function renderGoals() {
const container = document.getElementById("goals-list");
if (goals.length === 0) {
container.innerHTML = `
<div class="empty-state">
<h3>No goals yet</h3>
<p>Create your first goal to get started!</p>
</div>
`;
return;
}
container.innerHTML = goals.map(goal => `
<div class="goal-card" data-goal-id="${goal.id}">
<div class="goal-info">
<div class="goal-title">${escapeHtml(goal.title)}</div>
<div class="goal-status ${goal.activated ? 'active' : 'inactive'}">
${goal.activated ? 'Active' : 'Inactive'}
</div>
</div>
<div class="goal-actions">
<button class="toggle-btn ${goal.activated ? 'deactivate' : 'activate'}"
onclick="toggleGoal(${goal.id})">
${goal.activated ? 'Deactivate' : 'Activate'}
</button>
<button class="edit-btn" onclick="editGoal(${goal.id})">Edit</button>
<button class="delete-btn" onclick="confirmDelete(${goal.id})">Delete</button>
</div>
</div>
`).join("");
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function openModal(goal = null) {
const modal = document.getElementById("goal-modal");
const modalTitle = document.getElementById("modal-title");
const goalId = document.getElementById("goal-id");
const goalTitle = document.getElementById("goal-title");
const error = document.getElementById("goal-error");
error.textContent = "";
if (goal) {
modalTitle.textContent = "Edit Goal";
goalId.value = goal.doc_id;
goalTitle.value = goal.title;
} else {
modalTitle.textContent = "Create Goal";
goalId.value = "";
goalTitle.value = "";
}
modal.classList.add("active");
}
function closeModal() {
document.getElementById("goal-modal").classList.remove("active");
}
async function handleGoalSubmit(event) {
event.preventDefault();
const error = document.getElementById("goal-error");
error.textContent = "";
const goalId = document.getElementById("goal-id").value;
const title = document.getElementById("goal-title").value.trim();
if (!title) {
error.textContent = "Title is required";
return;
}
try {
if (goalId) {
await put(`/api/goals/${goalId}`, { title });
} else {
await post("/api/goals", { title });
}
closeModal();
await loadGoals();
} catch (err) {
error.textContent = err.message;
}
}
async function toggleGoal(goalId) {
try {
await patch(`/api/goals/${goalId}/toggle`, {});
await loadGoals();
} catch (error) {
console.error("Failed to toggle goal:", error);
}
}
function editGoal(goalId) {
const goal = goals.find(g => g.id === goalId);
if (goal) {
openModal(goal);
}
}
function confirmDelete(goalId) {
deleteGoalId = goalId;
document.getElementById("delete-confirm-modal").classList.add("active");
}
function closeDeleteModal() {
document.getElementById("delete-confirm-modal").classList.remove("active");
deleteGoalId = null;
}
async function handleDelete() {
if (!deleteGoalId) return;
try {
await del(`/api/goals/${deleteGoalId}`);
closeDeleteModal();
await loadGoals();
} catch (error) {
console.error("Failed to delete goal:", error);
}
}
document.addEventListener("DOMContentLoaded", () => {
document.getElementById("create-goal-btn").addEventListener("click", () => openModal());
document.getElementById("modal-close").addEventListener("click", closeModal);
document.getElementById("modal-cancel").addEventListener("click", closeModal);
document.getElementById("goal-form").addEventListener("submit", handleGoalSubmit);
document.getElementById("delete-modal-close").addEventListener("click", closeDeleteModal);
document.getElementById("cancel-delete").addEventListener("click", closeDeleteModal);
document.getElementById("confirm-delete").addEventListener("click", handleDelete);
loadGoals();
});
+301
View File
@@ -0,0 +1,301 @@
let goals = [];
let tasks = [];
let finishedTasks = [];
let selectedGoalId = null;
let selectedTaskId = null;
let sortableInstance = null;
async function loadGoals() {
try {
goals = await get("/api/goals");
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("");
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 {
const allTasks = await get(`/api/tasks?goal_id=${selectedGoalId}`);
tasks = allTasks.filter(t => t.status !== "done");
finishedTasks = allTasks.filter(t => t.status === "done");
renderTasks();
renderFinishedTasks();
initSortable();
initScrollFocus();
const doingTask = tasks.find(t => t.status === "doing");
if (doingTask) {
scrollToTask(doingTask.id);
}
} catch (error) {
console.error("Failed to load tasks:", 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 renderFinishedTasks() {
const container = document.getElementById("finished-list");
if (finishedTasks.length === 0) {
container.innerHTML = '<p style="color: #7f8c8d;">No completed tasks yet</p>';
return;
}
container.innerHTML = finishedTasks.map(task => `
<div class="finished-item" onclick="selectTask(${task.id})">
<div class="finished-item-title">${escapeHtml(task.title)}</div>
<div class="finished-item-time">Completed: ${formatTime(task.finished_time)}</div>
</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");
}
}
function selectTask(taskId) {
selectedTaskId = taskId;
const task = [...tasks, ...finishedTasks].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 = "";
document.getElementById("side-panel").classList.add("active");
}
function closeSidePanel() {
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, ...finishedTasks].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();
}
document.addEventListener("DOMContentLoaded", () => {
document.getElementById("goal-selector").addEventListener("change", (e) => {
selectedGoalId = parseInt(e.target.value);
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);
loadGoals();
});