Tier 3: timetracking CRUD + time tracking with topbar timer

This commit is contained in:
2026-02-28 04:48:37 +00:00
parent 6ad642084d
commit 7b259b9597
6 changed files with 497 additions and 0 deletions

View File

@@ -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)

211
routers/time_tracking.py Normal file
View File

@@ -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)

View File

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

View File

@@ -68,6 +68,10 @@
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Weblinks
</a>
<a href="/time" class="nav-item {{ 'active' if active_nav == 'time' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
Time Log
</a>
<a href="/capture" class="nav-item {{ 'active' if active_nav == 'capture' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>
Capture
@@ -127,6 +131,15 @@
<span class="topbar-env">DEV</span>
{% endif %}
<div class="topbar-spacer"></div>
<!-- Timer Pill (populated by JS) -->
<div id="timer-pill" class="timer-pill hidden">
<div class="timer-pill-dot"></div>
<a id="timer-pill-task" class="timer-pill-task" href="#"></a>
<span id="timer-pill-elapsed" class="timer-pill-elapsed">0:00</span>
<form method="POST" action="/time/stop" style="display:inline;">
<button type="submit" class="timer-pill-stop" title="Stop timer">&#9632;</button>
</form>
</div>
<button class="search-trigger" onclick="openSearch()" title="Search (Cmd/K)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<span>Search...</span>

155
templates/time_entries.html Normal file
View File

@@ -0,0 +1,155 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<div>
<h1 class="page-title">Time Log</h1>
<div class="text-muted text-sm mt-1">
{{ entries | length }} entries
&middot; {{ (total_minutes / 60) | round(1) }}h total
(last {{ days }} days)
</div>
</div>
</div>
<!-- Running timer banner -->
{% if running %}
<div class="card mb-3" style="border-color: var(--green); background: var(--green-soft);">
<div style="display: flex; align-items: center; gap: 12px;">
<div style="width: 10px; height: 10px; border-radius: 50%; background: var(--green); animation: pulse 1.5s infinite;"></div>
<div style="flex: 1;">
<div style="font-weight: 600; color: var(--text);">
Timer running: {{ running.task_title }}
</div>
{% if running.project_name %}
<div class="text-muted text-sm">{{ running.project_name }}</div>
{% endif %}
</div>
<div style="font-size: 1.25rem; font-weight: 700; font-family: var(--font-mono); color: var(--green);"
id="running-banner-elapsed" data-start="{{ running.start_at.isoformat() }}">
--:--
</div>
<form method="POST" action="/time/stop">
<input type="hidden" name="entry_id" value="{{ running.id }}">
<button type="submit" class="btn btn-danger btn-sm">Stop</button>
</form>
</div>
</div>
{% endif %}
<!-- Filters -->
<div class="filters-bar">
<a href="/time?days=1" class="btn {{ 'btn-primary' if days == 1 else 'btn-secondary' }} btn-sm">Today</a>
<a href="/time?days=7" class="btn {{ 'btn-primary' if days == 7 else 'btn-secondary' }} btn-sm">7 Days</a>
<a href="/time?days=30" class="btn {{ 'btn-primary' if days == 30 else 'btn-secondary' }} btn-sm">30 Days</a>
<a href="/time?days=90" class="btn {{ 'btn-primary' if days == 90 else 'btn-secondary' }} btn-sm">90 Days</a>
</div>
<!-- Daily totals summary -->
{% if daily_totals %}
<div class="card mb-3">
<div class="card-title mb-2">Daily Summary</div>
<div style="display: flex; flex-wrap: wrap; gap: 12px;">
{% for day, mins in daily_totals | dictsort(reverse=true) %}
<div style="text-align: center; padding: 8px 14px; background: var(--surface2); border-radius: var(--radius); min-width: 80px;">
<div style="font-size: 1.1rem; font-weight: 700;">{{ (mins / 60) | round(1) }}h</div>
<div class="text-muted text-xs">{{ day }}</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Time entries -->
{% if entries %}
<div class="card">
{% 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 %}
<div style="padding: 10px 12px 4px; font-size: 0.78rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; {% if not loop.first %}border-top: 1px solid var(--border); margin-top: 4px;{% endif %}">
{{ entry_date }}
</div>
{% set current_date.value = entry_date %}
{% endif %}
<div class="list-row">
{% if entry.end_at is none %}
<div style="width: 10px; height: 10px; border-radius: 50%; background: var(--green); animation: pulse 1.5s infinite; flex-shrink: 0;"></div>
{% endif %}
<div class="row-title">
<a href="/tasks/{{ entry.task_id }}">{{ entry.task_title }}</a>
</div>
{% if entry.project_name %}
<span class="row-tag">{{ entry.project_name }}</span>
{% endif %}
{% if entry.domain_name %}
<span class="row-domain-tag" style="background: {{ entry.domain_color or 'var(--accent)' }}20; color: {{ entry.domain_color or 'var(--accent)' }};">
{{ entry.domain_name }}
</span>
{% endif %}
<span style="font-family: var(--font-mono); font-size: 0.82rem; color: var(--text-secondary); min-width: 50px; text-align: right;">
{% if entry.end_at is none %}
<span style="color: var(--green);">running</span>
{% 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 %}
</span>
<span class="row-meta" style="min-width: 100px; text-align: right;">
{% if entry.start_at %}
{{ entry.start_at.strftime('%-I:%M %p') }}
{% if entry.end_at %}
- {{ entry.end_at.strftime('%-I:%M %p') }}
{% endif %}
{% endif %}
</span>
<div class="row-actions">
<form method="POST" action="/time/{{ entry.id }}/delete" data-confirm="Delete this time entry?">
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red);">Delete</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-state-icon">&#9201;</div>
<div class="empty-state-text">No time entries in the last {{ days }} days</div>
<div class="text-muted text-sm">Start a timer from any task to begin tracking time</div>
</div>
{% endif %}
<style>
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
</style>
<script>
// Update running banner elapsed time
(function() {
const el = document.getElementById('running-banner-elapsed');
if (!el) return;
const startAt = new Date(el.dataset.start);
function update() {
const now = new Date();
const secs = Math.floor((now - startAt) / 1000);
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
const s = secs % 60;
el.textContent = (h > 0 ? h + ':' : '') +
String(m).padStart(2, '0') + ':' +
String(s).padStart(2, '0');
}
update();
setInterval(update, 1000);
})();
</script>
{% endblock %}

50
timer-pill.css Normal file
View File

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