diff --git a/deploy-timer-buttons.sh b/deploy-timer-buttons.sh new file mode 100644 index 0000000..ceabfef --- /dev/null +++ b/deploy-timer-buttons.sh @@ -0,0 +1,693 @@ +#!/bin/bash +# Deploy timer buttons on task rows and task detail page +# Run from server: bash deploy-timer-buttons.sh + +set -e +cd /opt/lifeos/dev + +echo "=== Deploying timer buttons ===" + +# 1. Backup originals +cp routers/tasks.py routers/tasks.py.bak +cp templates/tasks.html templates/tasks.html.bak +cp templates/task_detail.html templates/task_detail.html.bak +cp static/style.css static/style.css.bak +echo "[OK] Backups created" + +# 2. Write routers/tasks.py +cat > routers/tasks.py << 'TASKSROUTER' +"""Tasks: core work items with full filtering and hierarchy.""" + +from fastapi import APIRouter, Request, Form, Depends +from fastapi.templating import Jinja2Templates +from fastapi.responses import RedirectResponse +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import text +from typing import Optional +from datetime import datetime, timezone + +from core.database import get_db +from core.base_repository import BaseRepository +from core.sidebar import get_sidebar_data + +router = APIRouter(prefix="/tasks", tags=["tasks"]) +templates = Jinja2Templates(directory="templates") + + +async def get_running_task_id(db: AsyncSession) -> Optional[str]: + """Get the task_id of the currently running timer, if any.""" + result = await db.execute(text( + "SELECT task_id FROM time_entries WHERE end_at IS NULL AND is_deleted = false LIMIT 1" + )) + row = result.first() + return str(row.task_id) if row else None + + +@router.get("/") +async def list_tasks( + request: Request, + domain_id: Optional[str] = None, + project_id: Optional[str] = None, + status: Optional[str] = None, + priority: Optional[str] = None, + context: Optional[str] = None, + sort: str = "sort_order", + db: AsyncSession = Depends(get_db), +): + sidebar = await get_sidebar_data(db) + + where_clauses = ["t.is_deleted = false"] + params = {} + if domain_id: + where_clauses.append("t.domain_id = :domain_id") + params["domain_id"] = domain_id + if project_id: + where_clauses.append("t.project_id = :project_id") + params["project_id"] = project_id + if status: + where_clauses.append("t.status = :status") + params["status"] = status + if priority: + where_clauses.append("t.priority = :priority") + params["priority"] = int(priority) + if context: + where_clauses.append("t.context = :context") + params["context"] = context + + where_sql = " AND ".join(where_clauses) + + sort_map = { + "sort_order": "t.sort_order, t.created_at", + "priority": "t.priority ASC, t.due_date ASC NULLS LAST", + "due_date": "t.due_date ASC NULLS LAST, t.priority ASC", + "created_at": "t.created_at DESC", + "title": "t.title ASC", + } + order_sql = sort_map.get(sort, sort_map["sort_order"]) + + result = await db.execute(text(f""" + SELECT t.*, + d.name as domain_name, d.color as domain_color, + p.name as project_name + FROM tasks t + LEFT JOIN domains d ON t.domain_id = d.id + LEFT JOIN projects p ON t.project_id = p.id + WHERE {where_sql} + ORDER BY + CASE WHEN t.status = 'done' THEN 1 WHEN t.status = 'cancelled' THEN 2 ELSE 0 END, + {order_sql} + """), params) + items = [dict(r._mapping) for r in result] + + # Get filter options + domains_repo = BaseRepository("domains", db) + domains = await domains_repo.list() + projects_repo = BaseRepository("projects", db) + projects = await projects_repo.list() + + result = await db.execute(text( + "SELECT value, label FROM context_types WHERE is_deleted = false ORDER BY sort_order" + )) + context_types = [dict(r._mapping) for r in result] + + running_task_id = await get_running_task_id(db) + + return templates.TemplateResponse("tasks.html", { + "request": request, "sidebar": sidebar, "items": items, + "domains": domains, "projects": projects, "context_types": context_types, + "current_domain_id": domain_id or "", + "current_project_id": project_id or "", + "current_status": status or "", + "current_priority": priority or "", + "current_context": context or "", + "current_sort": sort, + "running_task_id": running_task_id, + "page_title": "All Tasks", "active_nav": "tasks", + }) + + +@router.get("/create") +async def create_form( + request: Request, + domain_id: Optional[str] = None, + project_id: Optional[str] = None, + parent_id: Optional[str] = None, + db: AsyncSession = Depends(get_db), +): + sidebar = await get_sidebar_data(db) + domains_repo = BaseRepository("domains", db) + domains = await domains_repo.list() + projects_repo = BaseRepository("projects", db) + projects = await projects_repo.list() + result = await db.execute(text( + "SELECT value, label FROM context_types WHERE is_deleted = false ORDER BY sort_order" + )) + context_types = [dict(r._mapping) for r in result] + + return templates.TemplateResponse("task_form.html", { + "request": request, "sidebar": sidebar, + "domains": domains, "projects": projects, "context_types": context_types, + "page_title": "New Task", "active_nav": "tasks", + "item": None, + "prefill_domain_id": domain_id or "", + "prefill_project_id": project_id or "", + "prefill_parent_id": parent_id or "", + }) + + +@router.post("/create") +async def create_task( + request: Request, + title: str = Form(...), + domain_id: str = Form(...), + project_id: Optional[str] = Form(None), + parent_id: Optional[str] = Form(None), + description: Optional[str] = Form(None), + priority: int = Form(3), + status: str = Form("open"), + due_date: Optional[str] = Form(None), + deadline: Optional[str] = Form(None), + context: Optional[str] = Form(None), + tags: Optional[str] = Form(None), + estimated_minutes: Optional[str] = Form(None), + energy_required: Optional[str] = Form(None), + db: AsyncSession = Depends(get_db), +): + repo = BaseRepository("tasks", db) + data = { + "title": title, "domain_id": domain_id, + "description": description, "priority": priority, "status": status, + } + if project_id and project_id.strip(): + data["project_id"] = project_id + if parent_id and parent_id.strip(): + data["parent_id"] = parent_id + if due_date and due_date.strip(): + data["due_date"] = due_date + if deadline and deadline.strip(): + data["deadline"] = deadline + if context and context.strip(): + data["context"] = context + if tags and tags.strip(): + data["tags"] = [t.strip() for t in tags.split(",") if t.strip()] + if estimated_minutes and estimated_minutes.strip(): + data["estimated_minutes"] = int(estimated_minutes) + if energy_required and energy_required.strip(): + data["energy_required"] = energy_required + + task = await repo.create(data) + + # Redirect back to project if created from project context + if data.get("project_id"): + return RedirectResponse(url=f"/projects/{data['project_id']}?tab=tasks", status_code=303) + return RedirectResponse(url="/tasks", status_code=303) + + +@router.get("/{task_id}") +async def task_detail(task_id: str, request: Request, db: AsyncSession = Depends(get_db)): + repo = BaseRepository("tasks", db) + sidebar = await get_sidebar_data(db) + item = await repo.get(task_id) + if not item: + return RedirectResponse(url="/tasks", status_code=303) + + # Domain and project info + domain = None + if item.get("domain_id"): + result = await db.execute(text("SELECT name, color FROM domains WHERE id = :id"), {"id": str(item["domain_id"])}) + row = result.first() + domain = dict(row._mapping) if row else None + + project = None + if item.get("project_id"): + result = await db.execute(text("SELECT id, name FROM projects WHERE id = :id"), {"id": str(item["project_id"])}) + row = result.first() + project = dict(row._mapping) if row else None + + parent = None + if item.get("parent_id"): + result = await db.execute(text("SELECT id, title FROM tasks WHERE id = :id"), {"id": str(item["parent_id"])}) + row = result.first() + parent = dict(row._mapping) if row else None + + # Subtasks + result = await db.execute(text(""" + SELECT * FROM tasks WHERE parent_id = :tid AND is_deleted = false + ORDER BY sort_order, created_at + """), {"tid": task_id}) + subtasks = [dict(r._mapping) for r in result] + + running_task_id = await get_running_task_id(db) + + return templates.TemplateResponse("task_detail.html", { + "request": request, "sidebar": sidebar, "item": item, + "domain": domain, "project": project, "parent": parent, + "subtasks": subtasks, + "running_task_id": running_task_id, + "page_title": item["title"], "active_nav": "tasks", + }) + + +@router.get("/{task_id}/edit") +async def edit_form(task_id: str, request: Request, db: AsyncSession = Depends(get_db)): + repo = BaseRepository("tasks", db) + sidebar = await get_sidebar_data(db) + item = await repo.get(task_id) + if not item: + return RedirectResponse(url="/tasks", status_code=303) + + domains_repo = BaseRepository("domains", db) + domains = await domains_repo.list() + projects_repo = BaseRepository("projects", db) + projects = await projects_repo.list() + result = await db.execute(text( + "SELECT value, label FROM context_types WHERE is_deleted = false ORDER BY sort_order" + )) + context_types = [dict(r._mapping) for r in result] + + return templates.TemplateResponse("task_form.html", { + "request": request, "sidebar": sidebar, + "domains": domains, "projects": projects, "context_types": context_types, + "page_title": f"Edit Task", "active_nav": "tasks", + "item": item, + "prefill_domain_id": "", "prefill_project_id": "", "prefill_parent_id": "", + }) + + +@router.post("/{task_id}/edit") +async def update_task( + task_id: str, + title: str = Form(...), + domain_id: str = Form(...), + project_id: Optional[str] = Form(None), + parent_id: Optional[str] = Form(None), + description: Optional[str] = Form(None), + priority: int = Form(3), + status: str = Form("open"), + due_date: Optional[str] = Form(None), + deadline: Optional[str] = Form(None), + context: Optional[str] = Form(None), + tags: Optional[str] = Form(None), + estimated_minutes: Optional[str] = Form(None), + energy_required: Optional[str] = Form(None), + db: AsyncSession = Depends(get_db), +): + repo = BaseRepository("tasks", db) + data = { + "title": title, "domain_id": domain_id, + "description": description, "priority": priority, "status": status, + "project_id": project_id if project_id and project_id.strip() else None, + "parent_id": parent_id if parent_id and parent_id.strip() else None, + "due_date": due_date if due_date and due_date.strip() else None, + "deadline": deadline if deadline and deadline.strip() else None, + "context": context if context and context.strip() else None, + } + if tags and tags.strip(): + data["tags"] = [t.strip() for t in tags.split(",") if t.strip()] + else: + data["tags"] = None + if estimated_minutes and estimated_minutes.strip(): + data["estimated_minutes"] = int(estimated_minutes) + if energy_required and energy_required.strip(): + data["energy_required"] = energy_required + + # Handle completion + old = await repo.get(task_id) + if old and old["status"] != "done" and status == "done": + data["completed_at"] = datetime.now(timezone.utc) + elif status != "done": + data["completed_at"] = None + + await repo.update(task_id, data) + return RedirectResponse(url=f"/tasks/{task_id}", status_code=303) + + +@router.post("/{task_id}/complete") +async def complete_task(task_id: str, request: Request, db: AsyncSession = Depends(get_db)): + """Quick complete from list view.""" + repo = BaseRepository("tasks", db) + await repo.update(task_id, { + "status": "done", + "completed_at": datetime.now(timezone.utc), + }) + return RedirectResponse(url=request.headers.get("referer", "/tasks"), status_code=303) + + +@router.post("/{task_id}/toggle") +async def toggle_task(task_id: str, request: Request, db: AsyncSession = Depends(get_db)): + """Toggle task done/open from list view.""" + repo = BaseRepository("tasks", db) + task = await repo.get(task_id) + if not task: + return RedirectResponse(url="/tasks", status_code=303) + + if task["status"] == "done": + await repo.update(task_id, {"status": "open", "completed_at": None}) + else: + await repo.update(task_id, {"status": "done", "completed_at": datetime.now(timezone.utc)}) + + referer = request.headers.get("referer", "/tasks") + return RedirectResponse(url=referer, status_code=303) + + +@router.post("/{task_id}/delete") +async def delete_task(task_id: str, request: Request, db: AsyncSession = Depends(get_db)): + repo = BaseRepository("tasks", db) + await repo.soft_delete(task_id) + referer = request.headers.get("referer", "/tasks") + return RedirectResponse(url=referer, status_code=303) + + +# Quick add from any task list +@router.post("/quick-add") +async def quick_add( + request: Request, + title: str = Form(...), + domain_id: Optional[str] = Form(None), + project_id: Optional[str] = Form(None), + db: AsyncSession = Depends(get_db), +): + repo = BaseRepository("tasks", db) + data = {"title": title, "status": "open", "priority": 3} + if domain_id and domain_id.strip(): + data["domain_id"] = domain_id + if project_id and project_id.strip(): + data["project_id"] = project_id + + # If no domain, use first domain + if "domain_id" not in data: + result = await db.execute(text( + "SELECT id FROM domains WHERE is_deleted = false ORDER BY sort_order LIMIT 1" + )) + row = result.first() + if row: + data["domain_id"] = str(row[0]) + + await repo.create(data) + referer = request.headers.get("referer", "/tasks") + return RedirectResponse(url=referer, status_code=303) +TASKSROUTER +echo "[OK] routers/tasks.py" + +# 3. Write templates/tasks.html +cat > templates/tasks.html << 'TASKSHTML' +{% extends "base.html" %} +{% block content %} +