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 %} + + + +
+ + +
+ + +
+ + + + + +
+ + +{% if items %} +
+ {% for item in items %} +
+
+
+ + +
+
+ {% if item.status not in ['done', 'cancelled'] %} +
+ {% if running_task_id and item.id|string == running_task_id %} +
+ +
+ {% else %} +
+ + +
+ {% endif %} +
+ {% endif %} + + {{ item.title }} + {% if item.project_name %} + {{ item.project_name }} + {% endif %} + {% if item.domain_name %} + {{ item.domain_name }} + {% endif %} + {% if item.due_date %} + {{ item.due_date }} + {% endif %} + {{ item.status|replace('_', ' ') }} +
+ Edit +
+ +
+
+
+ {% endfor %} +
+{% else %} +
+
+
No tasks found
+ Create First Task +
+{% endif %} +{% endblock %} +TASKSHTML +echo "[OK] templates/tasks.html" + +# 4. Write templates/task_detail.html +cat > templates/task_detail.html << 'DETAILHTML' +{% extends "base.html" %} +{% block content %} + + +
+
+

{{ item.title }}

+
+ {% if item.status not in ['done', 'cancelled'] %} + {% if running_task_id and item.id|string == running_task_id %} +
+ +
+ {% else %} +
+ + +
+ {% endif %} + {% endif %} + Edit +
+ +
+
+
+
+ {{ item.status|replace('_', ' ') }} + P{{ item.priority }} + {% if domain %}{{ domain.name }}{% endif %} + {% if project %}{{ project.name }}{% endif %} + {% if item.due_date %}Due: {{ item.due_date }}{% endif %} + {% if item.context %}@{{ item.context }}{% endif %} + {% if item.estimated_minutes %}~{{ item.estimated_minutes }}min{% endif %} + {% if item.energy_required %}Energy: {{ item.energy_required }}{% endif %} +
+
+ +{% if item.description %} +
+
{{ item.description }}
+
+{% endif %} + +{% if item.tags %} +
+ {% for tag in item.tags %}{{ tag }}{% endfor %} +
+{% endif %} + +{% if parent %} +
+
Parent Task
+ {{ parent.title }} +
+{% endif %} + + +
+
+

Subtasks{{ subtasks|length }}

+ + Add Subtask +
+ {% for sub in subtasks %} +
+
+
+ + +
+
+ + {{ sub.title }} + {{ sub.status|replace('_', ' ') }} +
+ {% else %} +
No subtasks
+ {% endfor %} +
+ +
+ Created {{ item.created_at.strftime('%Y-%m-%d %H:%M') if item.created_at else '' }} + {% if item.completed_at %} | Completed {{ item.completed_at.strftime('%Y-%m-%d %H:%M') }}{% endif %} +
+{% endblock %} +DETAILHTML +echo "[OK] templates/task_detail.html" + +# 5. Append timer button CSS to style.css +cat >> static/style.css << 'TIMERCSS' + +/* Timer buttons on task rows */ +.row-timer { + display: flex; + align-items: center; + flex-shrink: 0; +} + +.timer-btn { + width: 24px; + height: 24px; + border: none; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + line-height: 1; + padding: 0; + transition: background 0.15s, transform 0.1s; +} + +.timer-btn:hover { + transform: scale(1.15); +} + +.timer-btn-play { + background: var(--green, #22c55e)22; + color: var(--green, #22c55e); +} + +.timer-btn-play:hover { + background: var(--green, #22c55e)44; +} + +.timer-btn-stop { + background: var(--red, #ef4444)22; + color: var(--red, #ef4444); +} + +.timer-btn-stop:hover { + background: var(--red, #ef4444)44; +} + +/* Highlight row with active timer */ +.list-row.timer-active { + border-left: 3px solid var(--green, #22c55e); + background: var(--green, #22c55e)08; +} + +/* Timer button on task detail page */ +.timer-detail-btn { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 13px; +} + +.timer-detail-play { + background: var(--green, #22c55e)18; + color: var(--green, #22c55e); + border: 1px solid var(--green, #22c55e)44; +} + +.timer-detail-play:hover { + background: var(--green, #22c55e)33; +} + +.timer-detail-stop { + background: var(--red, #ef4444)18; + color: var(--red, #ef4444); + border: 1px solid var(--red, #ef4444)44; +} + +.timer-detail-stop:hover { + background: var(--red, #ef4444)33; +} +TIMERCSS +echo "[OK] CSS appended to static/style.css" + +# 6. Clean up backups +rm -f routers/tasks.py.bak templates/tasks.html.bak templates/task_detail.html.bak static/style.css.bak +echo "[OK] Backups cleaned" + +# 7. Check hot reload +echo "" +echo "=== Checking container ===" +sleep 2 +docker logs lifeos-dev --tail 5 + +echo "" +echo "=== Deploy complete ===" +echo "Test: visit /tasks, click play on a task, verify topbar pill + green row highlight" diff --git a/routers/tasks.py b/routers/tasks.py index dffb523..bdab823 100644 --- a/routers/tasks.py +++ b/routers/tasks.py @@ -16,6 +16,15 @@ 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, @@ -83,6 +92,8 @@ async def list_tasks( )) 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, @@ -92,6 +103,7 @@ async def list_tasks( "current_priority": priority or "", "current_context": context or "", "current_sort": sort, + "running_task_id": running_task_id, "page_title": "All Tasks", "active_nav": "tasks", }) @@ -207,10 +219,13 @@ async def task_detail(task_id: str, request: Request, db: AsyncSession = Depends """), {"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", }) diff --git a/static/style.css b/static/style.css index 2bd5859..9694ede 100644 --- a/static/style.css +++ b/static/style.css @@ -1028,3 +1028,81 @@ a:hover { color: var(--accent-hover); } .page-content { padding: 16px; } } .search-type-appointments { background: var(--amber-soft); color: var(--amber); } + +/* Timer buttons on task rows */ +.row-timer { + display: flex; + align-items: center; + flex-shrink: 0; +} + +.timer-btn { + width: 24px; + height: 24px; + border: none; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + line-height: 1; + padding: 0; + transition: background 0.15s, transform 0.1s; +} + +.timer-btn:hover { + transform: scale(1.15); +} + +.timer-btn-play { + background: var(--green, #22c55e)22; + color: var(--green, #22c55e); +} + +.timer-btn-play:hover { + background: var(--green, #22c55e)44; +} + +.timer-btn-stop { + background: var(--red, #ef4444)22; + color: var(--red, #ef4444); +} + +.timer-btn-stop:hover { + background: var(--red, #ef4444)44; +} + +/* Highlight row with active timer */ +.list-row.timer-active { + border-left: 3px solid var(--green, #22c55e); + background: var(--green, #22c55e)08; +} + +/* Timer button on task detail page */ +.timer-detail-btn { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 13px; +} + +.timer-detail-play { + background: var(--green, #22c55e)18; + color: var(--green, #22c55e); + border: 1px solid var(--green, #22c55e)44; +} + +.timer-detail-play:hover { + background: var(--green, #22c55e)33; +} + +.timer-detail-stop { + background: var(--red, #ef4444)18; + color: var(--red, #ef4444); + border: 1px solid var(--red, #ef4444)44; +} + +.timer-detail-stop:hover { + background: var(--red, #ef4444)33; +} diff --git a/templates/task_detail.html b/templates/task_detail.html index 375ffbd..a1140b3 100644 --- a/templates/task_detail.html +++ b/templates/task_detail.html @@ -10,6 +10,18 @@

{{ item.title }}

+ {% if item.status not in ['done', 'cancelled'] %} + {% if running_task_id and item.id|string == running_task_id %} +
+ +
+ {% else %} +
+ + +
+ {% endif %} + {% endif %} Edit
+ + {% else %} +
+ + +
+ {% endif %} +
+ {% endif %} {{ item.title }} {% if item.project_name %}