Tier 3: timers CRUD + time tracking with topbar timer

This commit is contained in:
2026-02-28 05:03:16 +00:00
parent 7b259b9597
commit a1d24354a0
5 changed files with 813 additions and 1 deletions

693
deploy-timer-buttons.sh Normal file
View 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">&#9632;</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">&#9654;</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">&#9745;</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">&#9632; 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">&#9654; 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"

View File

@@ -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",
})

View File

@@ -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;
}

View File

@@ -10,6 +10,18 @@
<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">&#9632; 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">&#9654; 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">

View File

@@ -52,7 +52,7 @@
{% if items %}
<div class="card">
{% for item in items %}
<div class="list-row {{ 'completed' if item.status in ['done', 'cancelled'] }}">
<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' }}
@@ -60,6 +60,20 @@
<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">&#9632;</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">&#9654;</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 %}