419 lines
13 KiB
Python
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.
|