commit f3bffa40cda93aa548b12228535ae48c22c4913f Author: Yuyao Huang Date: Fri May 8 12:41:19 2026 +0800 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b265fbb --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Database +data/ + +# Virtual environment +.venv/ + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so + +# uv +uv.lock + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Environment variables +.env +.env.local diff --git a/README.md b/README.md new file mode 100644 index 0000000..fbc12a0 --- /dev/null +++ b/README.md @@ -0,0 +1,101 @@ +# GoalsBreakDown + +A web-based task management application focused on goal-oriented task tracking with a scroll-view interface. + +## Features + +- Multi-user support with registration and authentication +- Goal management with activation/deactivation +- Task management with status tracking (todo/doing/pending/done) +- Focus rule: Only one "doing" task per goal +- Scroll-view task selector with drag-and-drop reordering +- Admin panel for user management +- Per-user goal limits + +## Quick Start + +### Prerequisites + +- Python 3.10+ +- [uv](https://github.com/astral-sh/uv) package manager + +### Installation + +```bash +# Clone the repository +git clone +cd GoalsBreakDown + +# Install dependencies with uv +uv sync + +# Run the application +uv run python app.py +``` + +The application will start at **http://127.0.0.1:5000** + +### Default Admin Account + +- **Username:** `admin` +- **Password:** `admin123` + +**Important:** Change the default admin password after first login. + +## Configuration + +Edit `config.py` to customize settings: + +```python +# Database path +DB_PATH = "data/db.json" + +# Default admin credentials (change these!) +DEFAULT_ADMIN_USERNAME = "admin" +DEFAULT_ADMIN_PASSWORD = "admin123" + +# Default max goals for new users +DEFAULT_MAX_GOALS = 5 + +# Flask settings +SECRET_KEY = "your-secret-key-here" # Change in production +DEBUG = True +HOST = "0.0.0.0" +PORT = 5000 +``` + +### Production Deployment + +1. Change `SECRET_KEY` to a random secure string +2. Set `DEBUG = False` +3. Change default admin credentials +4. Use a production WSGI server (e.g., gunicorn): + +```bash +uv add gunicorn +uv run gunicorn -w 4 -b 0.0.0.0:5000 app:app +``` + +## Project Structure + +``` +GoalsBreakDown/ +├── app.py # Flask application +├── config.py # Configuration +├── database.py # TinyDB operations +├── auth.py # Authentication helpers +├── templates/ # HTML templates +├── static/ +│ ├── css/ # Stylesheets +│ └── js/ # JavaScript files +└── data/ # Database (auto-created, not tracked in git) +``` + +## Tech Stack + +- **Backend:** Python + Flask +- **Database:** TinyDB +- **Frontend:** Vanilla JS + HTML/CSS +- **Drag-and-Drop:** SortableJS +- **Authentication:** bcrypt + Flask sessions +- **Environment:** uv diff --git a/app.py b/app.py new file mode 100644 index 0000000..8f839bd --- /dev/null +++ b/app.py @@ -0,0 +1,397 @@ +from flask import Flask, render_template, request, jsonify, session, redirect, url_for +from datetime import datetime +import database +import auth +import config + +app = Flask(__name__) +app.secret_key = config.SECRET_KEY + +database.init_db() + + +@app.route("/") +def index(): + if "user_id" in session: + return redirect(url_for("goals_page")) + return redirect(url_for("login_page")) + + +@app.route("/login") +def login_page(): + return render_template("login.html") + + +@app.route("/register") +def register_page(): + return render_template("register.html") + + +@app.route("/goals") +@auth.login_required +def goals_page(): + return render_template("goals.html") + + +@app.route("/tasks") +@auth.login_required +def tasks_page(): + return render_template("tasks.html") + + +@app.route("/admin") +@auth.login_required +def admin_page(): + user = auth.get_current_user() + if not user or user.get("role") != "admin": + return redirect(url_for("goals_page")) + return render_template("admin.html") + + +@app.route("/api/auth/register", methods=["POST"]) +def api_register(): + data = request.get_json() + username = data.get("username", "").strip() + password = data.get("password", "") + + if not username or not password: + return jsonify({"success": False, "message": "Username and password are required"}), 400 + + if len(password) < 6: + return jsonify({"success": False, "message": "Password must be at least 6 characters"}), 400 + + if database.get_user_by_username(username): + return jsonify({"success": False, "message": "Username already exists"}), 400 + + password_hash = auth.hash_password(password) + user = database.create_user(username, password_hash) + + session["user_id"] = user.doc_id + session["username"] = username + + return jsonify({ + "success": True, + "message": "Registration successful", + "user_id": user.doc_id + }), 201 + + +@app.route("/api/auth/login", methods=["POST"]) +def api_login(): + data = request.get_json() + username = data.get("username", "").strip() + password = data.get("password", "") + + if not username or not password: + return jsonify({"success": False, "message": "Username and password are required"}), 400 + + user = database.get_user_by_username(username) + if not user or not auth.check_password(password, user["password_hash"]): + return jsonify({"success": False, "message": "Invalid credentials"}), 401 + + session["user_id"] = user.doc_id + session["username"] = username + + return jsonify({ + "success": True, + "message": "Login successful", + "user": { + "user_id": user.doc_id, + "username": user["username"], + "role": user["role"], + "max_goals": user["max_goals"] + } + }) + + +@app.route("/api/auth/logout", methods=["POST"]) +@auth.login_required +def api_logout(): + session.clear() + return jsonify({"success": True, "message": "Logged out"}) + + +@app.route("/api/auth/me", methods=["GET"]) +@auth.login_required +def api_me(): + user = auth.get_current_user() + if not user: + return jsonify({"success": False, "message": "User not found"}), 404 + return jsonify({ + "user_id": user.doc_id, + "username": user["username"], + "role": user["role"], + "max_goals": user["max_goals"] + }) + + +@app.route("/api/goals", methods=["GET"]) +@auth.login_required +def api_get_goals(): + user_id = session["user_id"] + goals = database.get_goals_by_user(user_id) + result = [] + for goal in goals: + goal_dict = dict(goal) + goal_dict["id"] = goal.doc_id + result.append(goal_dict) + return jsonify(result) + + +@app.route("/api/goals", methods=["POST"]) +@auth.login_required +def api_create_goal(): + data = request.get_json() + title = data.get("title", "").strip() + + if not title: + return jsonify({"success": False, "message": "Title is required"}), 400 + + user_id = session["user_id"] + user = database.get_user_by_id(user_id) + + if database.count_goals_by_user(user_id) >= user["max_goals"]: + return jsonify({ + "success": False, + "message": f"Goal limit reached ({user['max_goals']})" + }), 400 + + goal = database.create_goal(user_id, title) + return jsonify({"success": True, "goal_id": goal.doc_id}), 201 + + +@app.route("/api/goals/", methods=["PUT"]) +@auth.login_required +def api_update_goal(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() + updates = {} + + if "title" in data: + title = data["title"].strip() + if not title: + return jsonify({"success": False, "message": "Title cannot be empty"}), 400 + updates["title"] = title + + if "activated" in data: + updates["activated"] = bool(data["activated"]) + + database.update_goal(goal_id, **updates) + return jsonify({"success": True}) + + +@app.route("/api/goals/", methods=["DELETE"]) +@auth.login_required +def api_delete_goal(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 + + database.delete_goal(goal_id) + return jsonify({"success": True}) + + +@app.route("/api/goals//toggle", methods=["PATCH"]) +@auth.login_required +def api_toggle_goal(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 + + new_status = not goal["activated"] + database.update_goal(goal_id, activated=new_status) + return jsonify({"success": True, "activated": new_status}) + + +@app.route("/api/tasks", methods=["GET"]) +@auth.login_required +def api_get_tasks(): + goal_id = request.args.get("goal_id", type=int) + if not goal_id: + return jsonify([]) + + 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 + + tasks = database.get_tasks_sorted(goal_id) + result = [] + for task in tasks: + task_dict = dict(task) + task_dict["id"] = task.doc_id + result.append(task_dict) + return jsonify(result) + + +@app.route("/api/tasks", methods=["POST"]) +@auth.login_required +def api_create_task(): + data = request.get_json() + goal_id = data.get("goal_id") + title = data.get("title", "").strip() + desc = data.get("desc", "").strip() + + if not goal_id or not title: + return jsonify({"success": False, "message": "Goal ID and title are required"}), 400 + + 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 + + if not goal["activated"]: + return jsonify({"success": False, "message": "Cannot add task to deactivated goal"}), 400 + + task = database.create_task(goal_id, title, desc) + return jsonify({"success": True, "task_id": task.doc_id}), 201 + + +@app.route("/api/tasks/", methods=["PUT"]) +@auth.login_required +def api_update_task(task_id): + task = database.get_task_by_id(task_id) + if not task: + return jsonify({"success": False, "message": "Task not found"}), 404 + + goal = database.get_goal_by_id(task["goal_id"]) + if goal["user_id"] != session["user_id"]: + return jsonify({"success": False, "message": "Task not found"}), 404 + + data = request.get_json() + updates = {} + + if "title" in data: + title = data["title"].strip() + if not title: + return jsonify({"success": False, "message": "Title cannot be empty"}), 400 + updates["title"] = title + + if "desc" in data: + updates["desc"] = data["desc"] + + database.update_task(task_id, **updates) + return jsonify({"success": True}) + + +@app.route("/api/tasks/", methods=["DELETE"]) +@auth.login_required +def api_delete_task(task_id): + task = database.get_task_by_id(task_id) + if not task: + return jsonify({"success": False, "message": "Task not found"}), 404 + + goal = database.get_goal_by_id(task["goal_id"]) + if goal["user_id"] != session["user_id"]: + return jsonify({"success": False, "message": "Task not found"}), 404 + + database.delete_task(task_id) + return jsonify({"success": True}) + + +@app.route("/api/tasks//status", methods=["PATCH"]) +@auth.login_required +def api_update_task_status(task_id): + task = database.get_task_by_id(task_id) + if not task: + return jsonify({"success": False, "message": "Task not found"}), 404 + + goal = database.get_goal_by_id(task["goal_id"]) + if goal["user_id"] != session["user_id"]: + return jsonify({"success": False, "message": "Task not found"}), 404 + + data = request.get_json() + new_status = data.get("status") + + if new_status not in ["todo", "doing", "pending", "done"]: + return jsonify({"success": False, "message": "Invalid status"}), 400 + + updates = {"status": new_status} + + if new_status == "doing": + doing_task = None + all_tasks = database.get_tasks_by_goal(task["goal_id"]) + for t in all_tasks: + if t.get("status") == "doing" and t.doc_id != task.doc_id: + doing_task = t + break + if doing_task: + database.update_task(doing_task.doc_id, status="todo") + updates["start_time"] = datetime.now().isoformat() + else: + updates["start_time"] = None + + if new_status == "done": + updates["finished_time"] = datetime.now().isoformat() + else: + updates["finished_time"] = None + + database.update_task(task_id, **updates) + return jsonify({"success": True}) + + +@app.route("/api/tasks//order", methods=["PATCH"]) +@auth.login_required +def api_update_task_order(task_id): + task = database.get_task_by_id(task_id) + if not task: + return jsonify({"success": False, "message": "Task not found"}), 404 + + goal = database.get_goal_by_id(task["goal_id"]) + if goal["user_id"] != session["user_id"]: + return jsonify({"success": False, "message": "Task not found"}), 404 + + data = request.get_json() + new_order = data.get("order") + + if new_order is None: + return jsonify({"success": False, "message": "Order value required"}), 400 + + database.update_task(task_id, order=float(new_order)) + return jsonify({"success": True}) + + +@app.route("/api/admin/users", methods=["GET"]) +@auth.admin_required +def api_get_users(): + users = database.get_all_users() + result = [] + for user in users: + result.append({ + "user_id": user.doc_id, + "username": user["username"], + "role": user["role"], + "max_goals": user["max_goals"] + }) + return jsonify(result) + + +@app.route("/api/admin/users/", methods=["PUT"]) +@auth.admin_required +def api_update_user(user_id): + user = database.get_user_by_id(user_id) + if not user: + return jsonify({"success": False, "message": "User not found"}), 404 + + data = request.get_json() + updates = {} + + if "max_goals" in data: + max_goals = data["max_goals"] + if not isinstance(max_goals, int) or max_goals < 1: + return jsonify({"success": False, "message": "Invalid max_goals value"}), 400 + updates["max_goals"] = max_goals + + if "role" in data: + role = data["role"] + if role not in ["user", "admin"]: + return jsonify({"success": False, "message": "Invalid role"}), 400 + updates["role"] = role + + database.update_user(user_id, **updates) + return jsonify({"success": True}) + + +if __name__ == "__main__": + app.run(host=config.HOST, port=config.PORT, debug=config.DEBUG) diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..af26dd9 --- /dev/null +++ b/auth.py @@ -0,0 +1,45 @@ +from functools import wraps +from flask import session, redirect, url_for, jsonify +import bcrypt +import database + + +def hash_password(password): + return bcrypt.hashpw( + password.encode("utf-8"), + bcrypt.gensalt() + ).decode("utf-8") + + +def check_password(password, password_hash): + return bcrypt.checkpw( + password.encode("utf-8"), + password_hash.encode("utf-8") + ) + + +def login_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if "user_id" not in session: + return jsonify({"success": False, "message": "Not authenticated"}), 401 + return f(*args, **kwargs) + return decorated_function + + +def admin_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if "user_id" not in session: + return jsonify({"success": False, "message": "Not authenticated"}), 401 + user = database.get_user_by_id(session["user_id"]) + if not user or user.get("role") != "admin": + return jsonify({"success": False, "message": "Admin access required"}), 403 + return f(*args, **kwargs) + return decorated_function + + +def get_current_user(): + if "user_id" not in session: + return None + return database.get_user_by_id(session["user_id"]) diff --git a/config.py b/config.py new file mode 100644 index 0000000..e8f1ecb --- /dev/null +++ b/config.py @@ -0,0 +1,13 @@ +import os + +DB_PATH = os.path.join(os.path.dirname(__file__), "data", "db.json") + +DEFAULT_ADMIN_USERNAME = "admin" +DEFAULT_ADMIN_PASSWORD = "admin123" + +DEFAULT_MAX_GOALS = 5 + +SECRET_KEY = "goals-breakdown-secret-key-change-in-production" +DEBUG = True +HOST = "0.0.0.0" +PORT = 5000 diff --git a/database.py b/database.py new file mode 100644 index 0000000..2d41133 --- /dev/null +++ b/database.py @@ -0,0 +1,137 @@ +import os +from tinydb import TinyDB, Query +from tinydb.operations import increment +import config + +os.makedirs(os.path.dirname(config.DB_PATH), exist_ok=True) + +db = TinyDB(config.DB_PATH) + +users_table = db.table("users") +goals_table = db.table("goals") +tasks_table = db.table("tasks") + +User = Query() +Goal = Query() +Task = Query() + + +def init_db(): + if users_table.count(User.role == "admin") == 0: + import bcrypt + password_hash = bcrypt.hashpw( + config.DEFAULT_ADMIN_PASSWORD.encode("utf-8"), + bcrypt.gensalt() + ).decode("utf-8") + users_table.insert({ + "username": config.DEFAULT_ADMIN_USERNAME, + "password_hash": password_hash, + "role": "admin", + "max_goals": 100 + }) + + +def get_user_by_username(username): + result = users_table.search(User.username == username) + return result[0] if result else None + + +def get_user_by_id(user_id): + return users_table.get(doc_id=user_id) + + +def create_user(username, password_hash, role="user", max_goals=None): + if max_goals is None: + max_goals = config.DEFAULT_MAX_GOALS + doc_id = users_table.insert({ + "username": username, + "password_hash": password_hash, + "role": role, + "max_goals": max_goals + }) + return users_table.get(doc_id=doc_id) + + +def update_user(user_id, **kwargs): + users_table.update(kwargs, doc_ids=[user_id]) + + +def get_all_users(): + return users_table.all() + + +def get_goals_by_user(user_id): + return goals_table.search(Goal.user_id == user_id) + + +def get_goal_by_id(goal_id): + return goals_table.get(doc_id=goal_id) + + +def create_goal(user_id, title): + doc_id = goals_table.insert({ + "user_id": user_id, + "title": title, + "activated": True + }) + return goals_table.get(doc_id=doc_id) + + +def update_goal(goal_id, **kwargs): + goals_table.update(kwargs, doc_ids=[goal_id]) + + +def delete_goal(goal_id): + tasks = tasks_table.search(Task.goal_id == goal_id) + for task in tasks: + tasks_table.remove(doc_ids=[task.doc_id]) + goals_table.remove(doc_ids=[goal_id]) + + +def count_goals_by_user(user_id): + return len(goals_table.search(Goal.user_id == user_id)) + + +def get_tasks_by_goal(goal_id): + return tasks_table.search(Task.goal_id == goal_id) + + +def get_task_by_id(task_id): + return tasks_table.get(doc_id=task_id) + + +def create_task(goal_id, title, desc="", status="todo", order=None): + if order is None: + existing_tasks = tasks_table.search(Task.goal_id == goal_id) + unfinished = [t for t in existing_tasks if t.get("status") != "done"] + order = max([t.get("order", 0) for t in unfinished], default=0) + 1.0 + + doc_id = tasks_table.insert({ + "goal_id": goal_id, + "title": title, + "desc": desc, + "status": status, + "start_time": None, + "finished_time": None, + "order": order + }) + return tasks_table.get(doc_id=doc_id) + + +def update_task(task_id, **kwargs): + tasks_table.update(kwargs, doc_ids=[task_id]) + + +def delete_task(task_id): + tasks_table.remove(doc_ids=[task_id]) + + +def get_tasks_sorted(goal_id): + all_tasks = tasks_table.search(Task.goal_id == goal_id) + unfinished = [t for t in all_tasks if t.get("status") != "done"] + finished = [t for t in all_tasks if t.get("status") == "done"] + + unfinished.sort(key=lambda t: t.get("order", 0)) + finished.sort(key=lambda t: t.get("finished_time", ""), reverse=True) + + return unfinished + finished diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..92831f9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "goalsbreakdown" +version = "0.1.0" +description = "Add your description here" +requires-python = ">=3.13" +dependencies = [ + "bcrypt>=5.0.0", + "flask>=3.1.3", + "tinydb>=4.8.2", +] diff --git a/static/css/goals.css b/static/css/goals.css new file mode 100644 index 0000000..ec47f77 --- /dev/null +++ b/static/css/goals.css @@ -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; +} diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..aaa6210 --- /dev/null +++ b/static/css/style.css @@ -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; +} diff --git a/static/css/tasks.css b/static/css/tasks.css new file mode 100644 index 0000000..f01f9ed --- /dev/null +++ b/static/css/tasks.css @@ -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; +} diff --git a/static/js/api.js b/static/js/api.js new file mode 100644 index 0000000..a33a112 --- /dev/null +++ b/static/js/api.js @@ -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 }); +} diff --git a/static/js/auth.js b/static/js/auth.js new file mode 100644 index 0000000..1e4f0b8 --- /dev/null +++ b/static/js/auth.js @@ -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(); + } +}); diff --git a/static/js/goals.js b/static/js/goals.js new file mode 100644 index 0000000..96edbe4 --- /dev/null +++ b/static/js/goals.js @@ -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 = ` +
+

No goals yet

+

Create your first goal to get started!

+
+ `; + return; + } + + container.innerHTML = goals.map(goal => ` +
+
+
${escapeHtml(goal.title)}
+
+ ${goal.activated ? 'Active' : 'Inactive'} +
+
+
+ + + +
+
+ `).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(); +}); diff --git a/static/js/tasks.js b/static/js/tasks.js new file mode 100644 index 0000000..0f1f29c --- /dev/null +++ b/static/js/tasks.js @@ -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 => + `` + ).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 = ` +
+

No tasks yet

+

Create your first task for this goal!

+
+ `; + return; + } + + container.innerHTML = tasks.map(task => ` +
+
${escapeHtml(task.title)}
+ ${task.status} +
+ `).join(""); +} + +function renderFinishedTasks() { + const container = document.getElementById("finished-list"); + + if (finishedTasks.length === 0) { + container.innerHTML = '

No completed tasks yet

'; + return; + } + + container.innerHTML = finishedTasks.map(task => ` +
+
${escapeHtml(task.title)}
+
Completed: ${formatTime(task.finished_time)}
+
+ `).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(); +}); diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000..128f06c --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,165 @@ +{% extends "base.html" %} + +{% block title %}Admin - GoalsBreakDown{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+

Admin Panel

+
+
+ + + + + + + + + + + +
User IDUsernameRoleMax GoalsActions
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..f8d4985 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,28 @@ + + + + + + {% block title %}GoalsBreakDown{% endblock %} + + {% block extra_css %}{% endblock %} + + + +
+ {% block content %}{% endblock %} +
+ + + {% block extra_js %}{% endblock %} + + diff --git a/templates/goals.html b/templates/goals.html new file mode 100644 index 0000000..49e3eb1 --- /dev/null +++ b/templates/goals.html @@ -0,0 +1,56 @@ +{% extends "base.html" %} + +{% block title %}Goals - GoalsBreakDown{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+

My Goals

+ +
+
+
+ + + + +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..ab9d874 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,29 @@ + + + + + + Login - GoalsBreakDown + + + +
+

Login

+
+
+ + +
+
+ + +
+
+ +
+ +
+ + + + diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..4304751 --- /dev/null +++ b/templates/register.html @@ -0,0 +1,33 @@ + + + + + + Register - GoalsBreakDown + + + +
+

Register

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+ + + + diff --git a/templates/tasks.html b/templates/tasks.html new file mode 100644 index 0000000..cce4746 --- /dev/null +++ b/templates/tasks.html @@ -0,0 +1,93 @@ +{% extends "base.html" %} + +{% block title %}Tasks - GoalsBreakDown{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+

Tasks

+
+ + +
+
+ +
+
+
+
+ +
+

Completed Tasks

+
+
+
+ + +
+ + + +
+
+

Edit Task

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+{% endblock %} + +{% block extra_js %} + + +{% endblock %}