Files
Timmy-time-dashboard/src/dashboard/routes/calm.py
Claude (Opus 4.6) 300d9575f1
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
[claude] Fix Starlette 1.0.0 TemplateResponse API in calm and tools routes (#1112) (#1115)
2026-03-23 18:14:36 +00:00

419 lines
13 KiB
Python

import logging
from datetime import UTC, date, datetime
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from dashboard.models.calm import JournalEntry, Task, TaskCertainty, TaskState
from dashboard.models.database import create_tables, get_db
from dashboard.templating import templates
# Ensure CALM tables exist (safe to call multiple times)
create_tables()
logger = logging.getLogger(__name__)
router = APIRouter(tags=["calm"])
# Helper functions for state machine logic
def get_now_task(db: Session) -> Task | None:
"""Return the single active NOW task, or None."""
return db.query(Task).filter(Task.state == TaskState.NOW).first()
def get_next_task(db: Session) -> Task | None:
"""Return the single queued NEXT task, or None."""
return db.query(Task).filter(Task.state == TaskState.NEXT).first()
def get_later_tasks(db: Session) -> list[Task]:
"""Return all LATER tasks ordered by MIT flag then sort_order."""
return (
db.query(Task)
.filter(Task.state == TaskState.LATER)
.order_by(Task.is_mit.desc(), Task.sort_order)
.all()
)
def _create_mit_tasks(db: Session, titles: list[str | None]) -> list[int]:
"""Create MIT tasks from a list of titles, return their IDs."""
task_ids: list[int] = []
for title in titles:
if title:
task = Task(
title=title,
is_mit=True,
state=TaskState.LATER,
certainty=TaskCertainty.SOFT,
)
db.add(task)
db.commit()
db.refresh(task)
task_ids.append(task.id)
return task_ids
def _create_other_tasks(db: Session, other_tasks: str):
"""Create non-MIT tasks from newline-separated text."""
for line in other_tasks.split("\n"):
line = line.strip()
if line:
task = Task(
title=line,
state=TaskState.LATER,
certainty=TaskCertainty.FUZZY,
)
db.add(task)
def _seed_now_next(db: Session):
"""Set initial NOW/NEXT states when both slots are empty."""
if get_now_task(db) or get_next_task(db):
return
later_tasks = (
db.query(Task)
.filter(Task.state == TaskState.LATER)
.order_by(Task.is_mit.desc(), Task.sort_order)
.all()
)
if later_tasks:
later_tasks[0].state = TaskState.NOW
db.add(later_tasks[0])
db.flush()
if len(later_tasks) > 1:
later_tasks[1].state = TaskState.NEXT
db.add(later_tasks[1])
def promote_tasks(db: Session):
"""Enforce the NOW/NEXT/LATER state machine invariants.
- At most one NOW task (extras demoted to NEXT).
- If no NOW, promote NEXT -> NOW.
- If no NEXT, promote highest-priority LATER -> NEXT.
"""
# Ensure only one NOW task exists. If multiple, demote extras to NEXT.
now_tasks = db.query(Task).filter(Task.state == TaskState.NOW).all()
if len(now_tasks) > 1:
# Keep the one with highest priority/sort_order, demote others to NEXT
now_tasks.sort(key=lambda t: (t.is_mit, t.sort_order), reverse=True)
for task_to_demote in now_tasks[1:]:
task_to_demote.state = TaskState.NEXT
db.add(task_to_demote)
db.flush() # Make changes visible
# If no NOW task, promote NEXT to NOW
current_now = db.query(Task).filter(Task.state == TaskState.NOW).first()
if not current_now:
next_task = db.query(Task).filter(Task.state == TaskState.NEXT).first()
if next_task:
next_task.state = TaskState.NOW
db.add(next_task)
db.flush() # Make changes visible
# If no NEXT task, promote highest priority LATER to NEXT
current_next = db.query(Task).filter(Task.state == TaskState.NEXT).first()
if not current_next:
later_tasks = (
db.query(Task)
.filter(Task.state == TaskState.LATER)
.order_by(Task.is_mit.desc(), Task.sort_order)
.all()
)
if later_tasks:
later_tasks[0].state = TaskState.NEXT
db.add(later_tasks[0])
db.commit()
# Endpoints
@router.get("/calm", response_class=HTMLResponse)
async def get_calm_view(request: Request, db: Session = Depends(get_db)):
"""Render the main CALM dashboard with NOW/NEXT/LATER counts."""
now_task = get_now_task(db)
next_task = get_next_task(db)
later_tasks_count = len(get_later_tasks(db))
return templates.TemplateResponse(
request,
"calm/calm_view.html",
{
"now_task": now_task,
"next_task": next_task,
"later_tasks_count": later_tasks_count,
},
)
@router.get("/calm/ritual/morning", response_class=HTMLResponse)
async def get_morning_ritual_form(request: Request):
"""Render the morning ritual intake form."""
return templates.TemplateResponse(request, "calm/morning_ritual_form.html", {})
@router.post("/calm/ritual/morning", response_class=HTMLResponse)
async def post_morning_ritual(
request: Request,
db: Session = Depends(get_db),
mit1_title: str = Form(None),
mit2_title: str = Form(None),
mit3_title: str = Form(None),
other_tasks: str = Form(""),
):
"""Process morning ritual: create MITs, other tasks, and set initial states."""
journal_entry = JournalEntry(entry_date=date.today())
db.add(journal_entry)
db.commit()
db.refresh(journal_entry)
journal_entry.mit_task_ids = _create_mit_tasks(db, [mit1_title, mit2_title, mit3_title])
db.add(journal_entry)
_create_other_tasks(db, other_tasks)
db.commit()
_seed_now_next(db)
db.commit()
return templates.TemplateResponse(
request,
"calm/calm_view.html",
{
"now_task": get_now_task(db),
"next_task": get_next_task(db),
"later_tasks_count": len(get_later_tasks(db)),
},
)
@router.get("/calm/ritual/evening", response_class=HTMLResponse)
async def get_evening_ritual_form(request: Request, db: Session = Depends(get_db)):
"""Render the evening ritual form for today's journal entry."""
journal_entry = db.query(JournalEntry).filter(JournalEntry.entry_date == date.today()).first()
if not journal_entry:
raise HTTPException(status_code=404, detail="No journal entry for today")
return templates.TemplateResponse(
request, "calm/evening_ritual_form.html", {"journal_entry": journal_entry}
)
@router.post("/calm/ritual/evening", response_class=HTMLResponse)
async def post_evening_ritual(
request: Request,
db: Session = Depends(get_db),
evening_reflection: str = Form(None),
gratitude: str = Form(None),
energy_level: int = Form(None),
):
"""Process evening ritual: save reflection/gratitude, archive active tasks."""
journal_entry = db.query(JournalEntry).filter(JournalEntry.entry_date == date.today()).first()
if not journal_entry:
raise HTTPException(status_code=404, detail="No journal entry for today")
journal_entry.evening_reflection = evening_reflection
journal_entry.gratitude = gratitude
journal_entry.energy_level = energy_level
db.add(journal_entry)
# Archive any remaining active tasks
active_tasks = (
db.query(Task)
.filter(Task.state.in_([TaskState.NOW, TaskState.NEXT, TaskState.LATER]))
.all()
)
for task in active_tasks:
task.state = TaskState.DEFERRED # Or DONE, depending on desired archiving logic
task.deferred_at = datetime.now(UTC)
db.add(task)
db.commit()
return templates.TemplateResponse(request, "calm/evening_ritual_complete.html", {})
@router.post("/calm/tasks", response_class=HTMLResponse)
async def create_new_task(
request: Request,
db: Session = Depends(get_db),
title: str = Form(...),
description: str | None = Form(None),
is_mit: bool = Form(False),
certainty: TaskCertainty = Form(TaskCertainty.SOFT),
):
"""Create a new task in LATER state and return updated count."""
task = Task(
title=title,
description=description,
is_mit=is_mit,
certainty=certainty,
state=TaskState.LATER,
)
db.add(task)
db.commit()
db.refresh(task)
# After creating a new task, we might need to re-evaluate NOW/NEXT/LATER, but for simplicity
# and given the spec, new tasks go to LATER. Promotion happens on completion/deferral.
return templates.TemplateResponse(
request,
"calm/partials/later_count.html",
{"later_tasks_count": len(get_later_tasks(db))},
)
@router.post("/calm/tasks/{task_id}/start", response_class=HTMLResponse)
async def start_task(
request: Request,
task_id: int,
db: Session = Depends(get_db),
):
"""Move a task to NOW state, demoting the current NOW to NEXT."""
current_now_task = get_now_task(db)
if current_now_task and current_now_task.id != task_id:
current_now_task.state = TaskState.NEXT # Demote current NOW to NEXT
db.add(current_now_task)
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
task.state = TaskState.NOW
task.started_at = datetime.now(UTC)
db.add(task)
db.commit()
# Re-evaluate NEXT from LATER if needed
promote_tasks(db)
return templates.TemplateResponse(
request,
"calm/partials/now_next_later.html",
{
"now_task": get_now_task(db),
"next_task": get_next_task(db),
"later_tasks_count": len(get_later_tasks(db)),
},
)
@router.post("/calm/tasks/{task_id}/complete", response_class=HTMLResponse)
async def complete_task(
request: Request,
task_id: int,
db: Session = Depends(get_db),
):
"""Mark a task as DONE and trigger state promotion."""
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
task.state = TaskState.DONE
task.completed_at = datetime.now(UTC)
db.add(task)
db.commit()
promote_tasks(db)
return templates.TemplateResponse(
request,
"calm/partials/now_next_later.html",
{
"now_task": get_now_task(db),
"next_task": get_next_task(db),
"later_tasks_count": len(get_later_tasks(db)),
},
)
@router.post("/calm/tasks/{task_id}/defer", response_class=HTMLResponse)
async def defer_task(
request: Request,
task_id: int,
db: Session = Depends(get_db),
):
"""Defer a task and trigger state promotion."""
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
task.state = TaskState.DEFERRED
task.deferred_at = datetime.now(UTC)
db.add(task)
db.commit()
promote_tasks(db)
return templates.TemplateResponse(
request,
"calm/partials/now_next_later.html",
{
"now_task": get_now_task(db),
"next_task": get_next_task(db),
"later_tasks_count": len(get_later_tasks(db)),
},
)
@router.get("/calm/partials/later_tasks_list", response_class=HTMLResponse)
async def get_later_tasks_list(request: Request, db: Session = Depends(get_db)):
"""Render the expandable list of LATER tasks."""
later_tasks = get_later_tasks(db)
return templates.TemplateResponse(
request, "calm/partials/later_tasks_list.html", {"later_tasks": later_tasks}
)
@router.post("/calm/tasks/reorder", response_class=HTMLResponse)
async def reorder_tasks(
request: Request,
db: Session = Depends(get_db),
# Expecting a comma-separated string of task IDs in new order
later_task_ids: str = Form(""),
next_task_id: int | None = Form(None),
):
"""Reorder LATER tasks and optionally promote one to NEXT."""
# Reorder LATER tasks
if later_task_ids:
ids_in_order = [int(x.strip()) for x in later_task_ids.split(",") if x.strip()]
for index, task_id in enumerate(ids_in_order):
task = db.query(Task).filter(Task.id == task_id).first()
if task and task.state == TaskState.LATER:
task.sort_order = index
db.add(task)
# Handle NEXT task if it's part of the reorder (e.g., moved from LATER to NEXT explicitly)
if next_task_id:
task = db.query(Task).filter(Task.id == next_task_id).first()
if (
task and task.state == TaskState.LATER
): # Only if it was a LATER task being promoted manually
# Demote current NEXT to LATER
current_next = get_next_task(db)
if current_next:
current_next.state = TaskState.LATER
current_next.sort_order = len(get_later_tasks(db)) # Add to end of later
db.add(current_next)
task.state = TaskState.NEXT
task.sort_order = 0 # NEXT tasks don't really need sort_order, but for consistency
db.add(task)
db.commit()
# Re-render the relevant parts of the UI
return templates.TemplateResponse(
request,
"calm/partials/now_next_later.html",
{
"now_task": get_now_task(db),
"next_task": get_next_task(db),
"later_tasks_count": len(get_later_tasks(db)),
},
)
# Include this router in the main FastAPI app
# Already registered in src/dashboard/app.py as calm_router.