Tier 3: timers CRUD + time tracking with topbar timer
This commit is contained in:
693
deploy-timer-buttons.sh
Normal file
693
deploy-timer-buttons.sh
Normal file
@@ -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 %}
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">All Tasks<span class="page-count">{{ items|length }}</span></h1>
|
||||
<a href="/tasks/create" class="btn btn-primary">+ New Task</a>
|
||||
</div>
|
||||
|
||||
<!-- Quick Add -->
|
||||
<form class="quick-add" action="/tasks/quick-add" method="post">
|
||||
<input type="text" name="title" placeholder="Quick add task..." required>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Add</button>
|
||||
</form>
|
||||
|
||||
<!-- Filters -->
|
||||
<form class="filters-bar" method="get" action="/tasks">
|
||||
<select name="status" class="filter-select" data-auto-submit onchange="this.form.submit()">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="open" {{ 'selected' if current_status == 'open' }}>Open</option>
|
||||
<option value="in_progress" {{ 'selected' if current_status == 'in_progress' }}>In Progress</option>
|
||||
<option value="blocked" {{ 'selected' if current_status == 'blocked' }}>Blocked</option>
|
||||
<option value="done" {{ 'selected' if current_status == 'done' }}>Done</option>
|
||||
</select>
|
||||
<select name="priority" class="filter-select" onchange="this.form.submit()">
|
||||
<option value="">All Priorities</option>
|
||||
<option value="1" {{ 'selected' if current_priority == '1' }}>Critical</option>
|
||||
<option value="2" {{ 'selected' if current_priority == '2' }}>High</option>
|
||||
<option value="3" {{ 'selected' if current_priority == '3' }}>Normal</option>
|
||||
<option value="4" {{ 'selected' if current_priority == '4' }}>Low</option>
|
||||
</select>
|
||||
<select name="domain_id" class="filter-select" onchange="this.form.submit()">
|
||||
<option value="">All Domains</option>
|
||||
{% for d in domains %}
|
||||
<option value="{{ d.id }}" {{ 'selected' if current_domain_id == d.id|string }}>{{ d.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select name="project_id" class="filter-select" onchange="this.form.submit()">
|
||||
<option value="">All Projects</option>
|
||||
{% for p in projects %}
|
||||
<option value="{{ p.id }}" {{ 'selected' if current_project_id == p.id|string }}>{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select name="sort" class="filter-select" onchange="this.form.submit()">
|
||||
<option value="sort_order" {{ 'selected' if current_sort == 'sort_order' }}>Manual Order</option>
|
||||
<option value="priority" {{ 'selected' if current_sort == 'priority' }}>Priority</option>
|
||||
<option value="due_date" {{ 'selected' if current_sort == 'due_date' }}>Due Date</option>
|
||||
<option value="created_at" {{ 'selected' if current_sort == 'created_at' }}>Newest</option>
|
||||
<option value="title" {{ 'selected' if current_sort == 'title' }}>Title</option>
|
||||
</select>
|
||||
</form>
|
||||
|
||||
<!-- Task List -->
|
||||
{% if items %}
|
||||
<div class="card">
|
||||
{% for item in items %}
|
||||
<div class="list-row {{ 'completed' if item.status in ['done', 'cancelled'] }} {{ 'timer-active' if running_task_id and item.id|string == running_task_id }}">
|
||||
<div class="row-check">
|
||||
<form action="/tasks/{{ item.id }}/toggle" method="post" style="display:inline">
|
||||
<input type="checkbox" id="check-{{ item.id }}" {{ 'checked' if item.status == 'done' }}
|
||||
onchange="this.form.submit()">
|
||||
<label for="check-{{ item.id }}"></label>
|
||||
</form>
|
||||
</div>
|
||||
{% if item.status not in ['done', 'cancelled'] %}
|
||||
<div class="row-timer">
|
||||
{% if running_task_id and item.id|string == running_task_id %}
|
||||
<form action="/time/stop" method="post" style="display:inline">
|
||||
<button type="submit" class="timer-btn timer-btn-stop" title="Stop timer">■</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form action="/time/start" method="post" style="display:inline">
|
||||
<input type="hidden" name="task_id" value="{{ item.id }}">
|
||||
<button type="submit" class="timer-btn timer-btn-play" title="Start timer">▶</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<span class="priority-dot priority-{{ item.priority }}"></span>
|
||||
<span class="row-title"><a href="/tasks/{{ item.id }}">{{ item.title }}</a></span>
|
||||
{% if item.project_name %}
|
||||
<span class="row-tag">{{ item.project_name }}</span>
|
||||
{% endif %}
|
||||
{% if item.domain_name %}
|
||||
<span class="row-domain-tag" style="background: {{ item.domain_color or '#4F6EF7' }}22; color: {{ item.domain_color or '#4F6EF7' }}">{{ item.domain_name }}</span>
|
||||
{% endif %}
|
||||
{% if item.due_date %}
|
||||
<span class="row-meta {{ 'overdue' if item.due_date|string < now_date|default('9999') }}">{{ item.due_date }}</span>
|
||||
{% endif %}
|
||||
<span class="status-badge status-{{ item.status }}">{{ item.status|replace('_', ' ') }}</span>
|
||||
<div class="row-actions">
|
||||
<a href="/tasks/{{ item.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
|
||||
<form action="/tasks/{{ item.id }}/delete" method="post" data-confirm="Delete this task?" style="display:inline">
|
||||
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Del</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">☑</div>
|
||||
<div class="empty-state-text">No tasks found</div>
|
||||
<a href="/tasks/create" class="btn btn-primary">Create First Task</a>
|
||||
</div>
|
||||
{% 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 %}
|
||||
<div class="breadcrumb">
|
||||
{% if domain %}<a href="/tasks?domain_id={{ item.domain_id }}">{{ domain.name }}</a><span class="sep">/</span>{% endif %}
|
||||
{% if project %}<a href="/projects/{{ project.id }}">{{ project.name }}</a><span class="sep">/</span>{% endif %}
|
||||
<span>{{ item.title }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-header">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="detail-title">{{ item.title }}</h1>
|
||||
<div class="flex gap-2">
|
||||
{% if item.status not in ['done', 'cancelled'] %}
|
||||
{% if running_task_id and item.id|string == running_task_id %}
|
||||
<form action="/time/stop" method="post" style="display:inline">
|
||||
<button class="btn btn-sm timer-detail-btn timer-detail-stop" title="Stop timer">■ Stop Timer</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form action="/time/start" method="post" style="display:inline">
|
||||
<input type="hidden" name="task_id" value="{{ item.id }}">
|
||||
<button class="btn btn-sm timer-detail-btn timer-detail-play" title="Start timer">▶ Start Timer</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<a href="/tasks/{{ item.id }}/edit" class="btn btn-secondary btn-sm">Edit</a>
|
||||
<form action="/tasks/{{ item.id }}/toggle" method="post" style="display:inline">
|
||||
<button class="btn {{ 'btn-secondary' if item.status == 'done' else 'btn-primary' }} btn-sm">
|
||||
{{ 'Reopen' if item.status == 'done' else 'Complete' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-meta mt-2">
|
||||
<span class="status-badge status-{{ item.status }}">{{ item.status|replace('_', ' ') }}</span>
|
||||
<span class="detail-meta-item"><span class="priority-dot priority-{{ item.priority }}"></span> P{{ item.priority }}</span>
|
||||
{% if domain %}<span class="row-domain-tag" style="background: {{ domain.color or '#4F6EF7' }}22; color: {{ domain.color or '#4F6EF7' }}">{{ domain.name }}</span>{% endif %}
|
||||
{% if project %}<span class="row-tag">{{ project.name }}</span>{% endif %}
|
||||
{% if item.due_date %}<span class="detail-meta-item">Due: {{ item.due_date }}</span>{% endif %}
|
||||
{% if item.context %}<span class="detail-meta-item">@{{ item.context }}</span>{% endif %}
|
||||
{% if item.estimated_minutes %}<span class="detail-meta-item">~{{ item.estimated_minutes }}min</span>{% endif %}
|
||||
{% if item.energy_required %}<span class="detail-meta-item">Energy: {{ item.energy_required }}</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if item.description %}
|
||||
<div class="card mb-4">
|
||||
<div class="detail-body">{{ item.description }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if item.tags %}
|
||||
<div class="flex gap-2 mb-4">
|
||||
{% for tag in item.tags %}<span class="row-tag">{{ tag }}</span>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if parent %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-title text-sm">Parent Task</div>
|
||||
<a href="/tasks/{{ parent.id }}">{{ parent.title }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Subtasks -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">Subtasks<span class="page-count">{{ subtasks|length }}</span></h2>
|
||||
<a href="/tasks/create?parent_id={{ item.id }}&domain_id={{ item.domain_id }}&project_id={{ item.project_id or '' }}" class="btn btn-ghost btn-sm">+ Add Subtask</a>
|
||||
</div>
|
||||
{% for sub in subtasks %}
|
||||
<div class="list-row {{ 'completed' if sub.status == 'done' }}">
|
||||
<div class="row-check">
|
||||
<form action="/tasks/{{ sub.id }}/toggle" method="post" style="display:inline">
|
||||
<input type="checkbox" id="sub-{{ sub.id }}" {{ 'checked' if sub.status == 'done' }} onchange="this.form.submit()">
|
||||
<label for="sub-{{ sub.id }}"></label>
|
||||
</form>
|
||||
</div>
|
||||
<span class="priority-dot priority-{{ sub.priority }}"></span>
|
||||
<span class="row-title"><a href="/tasks/{{ sub.id }}">{{ sub.title }}</a></span>
|
||||
<span class="status-badge status-{{ sub.status }}">{{ sub.status|replace('_', ' ') }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-sm text-muted" style="padding: 12px;">No subtasks</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-muted mt-4">
|
||||
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 %}
|
||||
</div>
|
||||
{% 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"
|
||||
Reference in New Issue
Block a user