diff --git a/main.py b/main.py index 00c4abb..46f4e08 100644 --- a/main.py +++ b/main.py @@ -38,6 +38,7 @@ from routers import ( decisions as decisions_router, weblinks as weblinks_router, appointments as appointments_router, + time_tracking as time_tracking_router, ) @@ -183,3 +184,4 @@ app.include_router(meetings_router.router) app.include_router(decisions_router.router) app.include_router(weblinks_router.router) app.include_router(appointments_router.router) +app.include_router(time_tracking_router.router) diff --git a/routers/time_tracking.py b/routers/time_tracking.py new file mode 100644 index 0000000..e96d413 --- /dev/null +++ b/routers/time_tracking.py @@ -0,0 +1,211 @@ +"""Time tracking: start/stop timer per task, manual time entries, time log view.""" + +from fastapi import APIRouter, Request, Depends, Form, Query +from fastapi.templating import Jinja2Templates +from fastapi.responses import RedirectResponse, JSONResponse +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.sidebar import get_sidebar_data + +router = APIRouter(prefix="/time", tags=["time"]) +templates = Jinja2Templates(directory="templates") + + +async def get_running_timer(db: AsyncSession) -> dict | None: + """Get the currently running timer (end_at IS NULL), if any.""" + result = await db.execute(text(""" + SELECT te.*, t.title as task_title, t.id as task_id, + p.name as project_name, d.name as domain_name + FROM time_entries te + JOIN tasks t ON te.task_id = t.id + LEFT JOIN projects p ON t.project_id = p.id + LEFT JOIN domains d ON t.domain_id = d.id + WHERE te.end_at IS NULL AND te.is_deleted = false + ORDER BY te.start_at DESC + LIMIT 1 + """)) + row = result.first() + return dict(row._mapping) if row else None + + +@router.get("/") +async def time_log( + request: Request, + task_id: Optional[str] = None, + days: int = Query(7, ge=1, le=90), + db: AsyncSession = Depends(get_db), +): + """Time entries log view.""" + sidebar = await get_sidebar_data(db) + running = await get_running_timer(db) + + params = {"days": days} + task_filter = "" + if task_id: + task_filter = "AND te.task_id = :task_id" + params["task_id"] = task_id + + result = await db.execute(text(f""" + SELECT te.*, t.title as task_title, t.id as task_id, + p.name as project_name, d.name as domain_name, d.color as domain_color + FROM time_entries te + JOIN tasks t ON te.task_id = t.id + LEFT JOIN projects p ON t.project_id = p.id + LEFT JOIN domains d ON t.domain_id = d.id + WHERE te.is_deleted = false + AND te.start_at >= CURRENT_DATE - INTERVAL ':days days' + {task_filter} + ORDER BY te.start_at DESC + LIMIT 200 + """.replace(":days days", f"{days} days")), params) + entries = [dict(r._mapping) for r in result] + + # Calculate totals + total_minutes = sum(e.get("duration_minutes") or 0 for e in entries) + + # Daily breakdown + daily_totals = {} + for e in entries: + if e.get("start_at"): + day = e["start_at"].strftime("%Y-%m-%d") + daily_totals[day] = daily_totals.get(day, 0) + (e.get("duration_minutes") or 0) + + return templates.TemplateResponse("time_entries.html", { + "request": request, + "sidebar": sidebar, + "entries": entries, + "running": running, + "total_minutes": total_minutes, + "daily_totals": daily_totals, + "days": days, + "task_id": task_id or "", + "page_title": "Time Log", + "active_nav": "time", + }) + + +@router.post("/start") +async def start_timer( + request: Request, + task_id: str = Form(...), + db: AsyncSession = Depends(get_db), +): + """Start a timer for a task. Auto-stops any running timer first.""" + now = datetime.now(timezone.utc) + + # Stop any currently running timer + running = await get_running_timer(db) + if running: + duration = int((now - running["start_at"]).total_seconds() / 60) + await db.execute(text(""" + UPDATE time_entries SET end_at = :now, duration_minutes = :dur + WHERE id = :id + """), {"now": now, "dur": max(duration, 1), "id": str(running["id"])}) + + # Start new timer + await db.execute(text(""" + INSERT INTO time_entries (task_id, start_at, is_deleted, created_at) + VALUES (:task_id, :now, false, :now) + """), {"task_id": task_id, "now": now}) + + # Redirect back to where they came from + referer = request.headers.get("referer", "/tasks") + return RedirectResponse(url=referer, status_code=303) + + +@router.post("/stop") +async def stop_timer( + request: Request, + entry_id: Optional[str] = Form(None), + db: AsyncSession = Depends(get_db), +): + """Stop the running timer (or a specific entry).""" + now = datetime.now(timezone.utc) + + if entry_id: + # Stop specific entry + result = await db.execute(text( + "SELECT * FROM time_entries WHERE id = :id AND end_at IS NULL" + ), {"id": entry_id}) + entry = result.first() + else: + # Stop whatever is running + result = await db.execute(text( + "SELECT * FROM time_entries WHERE end_at IS NULL AND is_deleted = false ORDER BY start_at DESC LIMIT 1" + )) + entry = result.first() + + if entry: + entry = dict(entry._mapping) + duration = int((now - entry["start_at"]).total_seconds() / 60) + await db.execute(text(""" + UPDATE time_entries SET end_at = :now, duration_minutes = :dur + WHERE id = :id + """), {"now": now, "dur": max(duration, 1), "id": str(entry["id"])}) + + referer = request.headers.get("referer", "/time") + return RedirectResponse(url=referer, status_code=303) + + +@router.get("/running") +async def running_timer_api(db: AsyncSession = Depends(get_db)): + """JSON endpoint for the topbar timer pill to poll.""" + running = await get_running_timer(db) + if not running: + return JSONResponse({"running": False}) + + elapsed_seconds = int((datetime.now(timezone.utc) - running["start_at"]).total_seconds()) + return JSONResponse({ + "running": True, + "entry_id": str(running["id"]), + "task_id": str(running["task_id"]), + "task_title": running["task_title"], + "project_name": running.get("project_name"), + "start_at": running["start_at"].isoformat(), + "elapsed_seconds": elapsed_seconds, + }) + + +@router.post("/manual") +async def manual_entry( + request: Request, + task_id: str = Form(...), + date: str = Form(...), + duration_minutes: int = Form(...), + notes: Optional[str] = Form(None), + db: AsyncSession = Depends(get_db), +): + """Add a manual time entry (no start/stop, just duration).""" + start_at = f"{date}T12:00:00+00:00" + + await db.execute(text(""" + INSERT INTO time_entries (task_id, start_at, end_at, duration_minutes, notes, is_deleted, created_at) + VALUES (:task_id, :start_at, :start_at, :dur, :notes, false, now()) + """), { + "task_id": task_id, + "start_at": start_at, + "dur": duration_minutes, + "notes": notes or None, + }) + + referer = request.headers.get("referer", "/time") + return RedirectResponse(url=referer, status_code=303) + + +@router.post("/{entry_id}/delete") +async def delete_entry( + request: Request, + entry_id: str, + db: AsyncSession = Depends(get_db), +): + # Direct SQL because time_entries has no updated_at column + await db.execute(text(""" + UPDATE time_entries SET is_deleted = true, deleted_at = now() + WHERE id = :id AND is_deleted = false + """), {"id": entry_id}) + referer = request.headers.get("referer", "/time") + return RedirectResponse(url=referer, status_code=303) diff --git a/static/app.js b/static/app.js index 3c11fd6..7da8477 100644 --- a/static/app.js +++ b/static/app.js @@ -168,3 +168,69 @@ function escHtml(s) { d.textContent = s; return d.innerHTML; } + + +// ---- Timer Pill (topbar running timer) ---- + +let timerStartAt = null; +let timerInterval = null; + +function formatElapsed(seconds) { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + if (h > 0) { + return h + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0'); + } + return m + ':' + String(s).padStart(2, '0'); +} + +function updateTimerPill() { + if (!timerStartAt) return; + const now = new Date(); + const secs = Math.floor((now - timerStartAt) / 1000); + const el = document.getElementById('timer-pill-elapsed'); + if (el) el.textContent = formatElapsed(secs); +} + +async function pollTimer() { + try { + const resp = await fetch('/time/running'); + const data = await resp.json(); + const pill = document.getElementById('timer-pill'); + if (!pill) return; + + if (data.running) { + pill.classList.remove('hidden'); + timerStartAt = new Date(data.start_at); + + const taskEl = document.getElementById('timer-pill-task'); + if (taskEl) { + taskEl.textContent = data.task_title; + taskEl.href = '/tasks/' + data.task_id; + } + + updateTimerPill(); + + // Start 1s interval if not already running + if (!timerInterval) { + timerInterval = setInterval(updateTimerPill, 1000); + } + } else { + pill.classList.add('hidden'); + timerStartAt = null; + if (timerInterval) { + clearInterval(timerInterval); + timerInterval = null; + } + } + } catch (err) { + // Silently ignore polling errors + } +} + +// Poll on load, then every 30s +document.addEventListener('DOMContentLoaded', () => { + pollTimer(); + setInterval(pollTimer, 30000); +}); diff --git a/templates/base.html b/templates/base.html index cf5f5ae..6bcc813 100644 --- a/templates/base.html +++ b/templates/base.html @@ -68,6 +68,10 @@ Weblinks + + + Time Log + Capture @@ -127,6 +131,15 @@ DEV {% endif %}
+ +
+ + + +{% endif %} + + +
+ Today + 7 Days + 30 Days + 90 Days +
+ + +{% if daily_totals %} +
+
Daily Summary
+
+ {% for day, mins in daily_totals | dictsort(reverse=true) %} +
+
{{ (mins / 60) | round(1) }}h
+
{{ day }}
+
+ {% endfor %} +
+
+{% endif %} + + +{% if entries %} +
+ {% set current_date = namespace(value='') %} + {% for entry in entries %} + {% set entry_date = entry.start_at.strftime('%A, %B %-d') if entry.start_at else 'Unknown' %} + {% if entry_date != current_date.value %} +
+ {{ entry_date }} +
+ {% set current_date.value = entry_date %} + {% endif %} + +
+ {% if entry.end_at is none %} +
+ {% endif %} + + {% if entry.project_name %} + {{ entry.project_name }} + {% endif %} + {% if entry.domain_name %} + + {{ entry.domain_name }} + + {% endif %} + + {% if entry.end_at is none %} + running + {% elif entry.duration_minutes %} + {% if entry.duration_minutes >= 60 %} + {{ (entry.duration_minutes / 60) | int }}h {{ entry.duration_minutes % 60 }}m + {% else %} + {{ entry.duration_minutes }}m + {% endif %} + {% else %} + -- + {% endif %} + + + {% if entry.start_at %} + {{ entry.start_at.strftime('%-I:%M %p') }} + {% if entry.end_at %} + - {{ entry.end_at.strftime('%-I:%M %p') }} + {% endif %} + {% endif %} + +
+
+ +
+
+
+ {% endfor %} +
+{% else %} +
+
+
No time entries in the last {{ days }} days
+
Start a timer from any task to begin tracking time
+
+{% endif %} + + + + +{% endblock %} diff --git a/timer-pill.css b/timer-pill.css new file mode 100644 index 0000000..6c87b88 --- /dev/null +++ b/timer-pill.css @@ -0,0 +1,50 @@ + +/* ---- Timer Pill (topbar) ---- */ +.timer-pill { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 12px; + background: var(--green-soft); + border: 1px solid var(--green); + border-radius: 20px; + font-size: 0.82rem; + transition: all var(--transition); +} +.timer-pill.hidden { display: none; } +.timer-pill-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--green); + animation: timer-pulse 1.5s infinite; +} +@keyframes timer-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} +.timer-pill-task { + color: var(--text); + font-weight: 500; + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.timer-pill-task:hover { color: var(--accent); } +.timer-pill-elapsed { + font-family: var(--font-mono); + font-weight: 600; + color: var(--green); + font-size: 0.85rem; +} +.timer-pill-stop { + background: none; + border: none; + color: var(--red); + font-size: 1rem; + cursor: pointer; + padding: 0 2px; + line-height: 1; +} +.timer-pill-stop:hover { color: #fff; }