R1 foundation - Phase 1 live build

This commit is contained in:
2026-02-28 03:33:33 +00:00
commit f36ea194f3
45 changed files with 4009 additions and 0 deletions

0
routers/__init__.py Normal file
View File

122
routers/areas.py Normal file
View File

@@ -0,0 +1,122 @@
"""Areas: grouping within domains."""
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 core.database import get_db
from core.base_repository import BaseRepository
from core.sidebar import get_sidebar_data
router = APIRouter(prefix="/areas", tags=["areas"])
templates = Jinja2Templates(directory="templates")
@router.get("/")
async def list_areas(
request: Request,
domain_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("areas", db)
sidebar = await get_sidebar_data(db)
filters = {}
if domain_id:
filters["domain_id"] = domain_id
result = await db.execute(text("""
SELECT a.*, d.name as domain_name, d.color as domain_color
FROM areas a
JOIN domains d ON a.domain_id = d.id
WHERE a.is_deleted = false
ORDER BY d.sort_order, d.name, a.sort_order, a.name
"""))
items = [dict(r._mapping) for r in result]
if domain_id:
items = [i for i in items if str(i["domain_id"]) == domain_id]
# Get domains for filter/form
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
return templates.TemplateResponse("areas.html", {
"request": request, "sidebar": sidebar, "items": items,
"domains": domains, "current_domain_id": domain_id or "",
"page_title": "Areas", "active_nav": "areas",
})
@router.get("/create")
async def create_form(
request: Request,
domain_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()
return templates.TemplateResponse("area_form.html", {
"request": request, "sidebar": sidebar, "domains": domains,
"page_title": "New Area", "active_nav": "areas",
"item": None, "prefill_domain_id": domain_id or "",
})
@router.post("/create")
async def create_area(
request: Request,
name: str = Form(...),
domain_id: str = Form(...),
description: Optional[str] = Form(None),
status: str = Form("active"),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("areas", db)
await repo.create({
"name": name, "domain_id": domain_id,
"description": description, "status": status,
})
return RedirectResponse(url="/areas", status_code=303)
@router.get("/{area_id}/edit")
async def edit_form(area_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("areas", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(area_id)
if not item:
return RedirectResponse(url="/areas", status_code=303)
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
return templates.TemplateResponse("area_form.html", {
"request": request, "sidebar": sidebar, "domains": domains,
"page_title": f"Edit {item['name']}", "active_nav": "areas",
"item": item, "prefill_domain_id": "",
})
@router.post("/{area_id}/edit")
async def update_area(
area_id: str,
name: str = Form(...),
domain_id: str = Form(...),
description: Optional[str] = Form(None),
status: str = Form("active"),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("areas", db)
await repo.update(area_id, {
"name": name, "domain_id": domain_id,
"description": description, "status": status,
})
return RedirectResponse(url="/areas", status_code=303)
@router.post("/{area_id}/delete")
async def delete_area(area_id: str, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("areas", db)
await repo.soft_delete(area_id)
return RedirectResponse(url="/areas", status_code=303)

92
routers/capture.py Normal file
View File

@@ -0,0 +1,92 @@
"""Capture: quick text capture queue with conversion."""
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 core.database import get_db
from core.base_repository import BaseRepository
from core.sidebar import get_sidebar_data
router = APIRouter(prefix="/capture", tags=["capture"])
templates = Jinja2Templates(directory="templates")
@router.get("/")
async def list_capture(request: Request, show: str = "unprocessed", db: AsyncSession = Depends(get_db)):
sidebar = await get_sidebar_data(db)
if show == "all":
filters = {}
else:
filters = {"processed": False}
result = await db.execute(text("""
SELECT * FROM capture WHERE is_deleted = false
AND (:show_all OR processed = false)
ORDER BY created_at DESC
"""), {"show_all": show == "all"})
items = [dict(r._mapping) for r in result]
return templates.TemplateResponse("capture.html", {
"request": request, "sidebar": sidebar, "items": items,
"show": show,
"page_title": "Capture", "active_nav": "capture",
})
@router.post("/add")
async def add_capture(
request: Request,
raw_text: str = Form(...),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("capture", db)
# Multi-line: split into individual items
lines = [l.strip() for l in raw_text.strip().split("\n") if l.strip()]
for line in lines:
await repo.create({"raw_text": line, "processed": False})
return RedirectResponse(url="/capture", status_code=303)
@router.post("/{capture_id}/to-task")
async def convert_to_task(
capture_id: str,
request: Request,
domain_id: str = Form(...),
project_id: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
capture_repo = BaseRepository("capture", db)
item = await capture_repo.get(capture_id)
if not item:
return RedirectResponse(url="/capture", status_code=303)
task_repo = BaseRepository("tasks", db)
data = {"title": item["raw_text"], "domain_id": domain_id, "status": "open", "priority": 3}
if project_id and project_id.strip():
data["project_id"] = project_id
task = await task_repo.create(data)
await capture_repo.update(capture_id, {
"processed": True,
"converted_to_type": "task",
"converted_to_id": str(task["id"]),
})
return RedirectResponse(url="/capture", status_code=303)
@router.post("/{capture_id}/dismiss")
async def dismiss_capture(capture_id: str, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("capture", db)
await repo.update(capture_id, {"processed": True})
return RedirectResponse(url="/capture", status_code=303)
@router.post("/{capture_id}/delete")
async def delete_capture(capture_id: str, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("capture", db)
await repo.soft_delete(capture_id)
return RedirectResponse(url="/capture", status_code=303)

126
routers/contacts.py Normal file
View File

@@ -0,0 +1,126 @@
"""Contacts: people directory for CRM."""
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 core.database import get_db
from core.base_repository import BaseRepository
from core.sidebar import get_sidebar_data
router = APIRouter(prefix="/contacts", tags=["contacts"])
templates = Jinja2Templates(directory="templates")
@router.get("/")
async def list_contacts(request: Request, db: AsyncSession = Depends(get_db)):
sidebar = await get_sidebar_data(db)
result = await db.execute(text("""
SELECT * FROM contacts WHERE is_deleted = false
ORDER BY sort_order, first_name, last_name
"""))
items = [dict(r._mapping) for r in result]
return templates.TemplateResponse("contacts.html", {
"request": request, "sidebar": sidebar, "items": items,
"page_title": "Contacts", "active_nav": "contacts",
})
@router.get("/create")
async def create_form(request: Request, db: AsyncSession = Depends(get_db)):
sidebar = await get_sidebar_data(db)
return templates.TemplateResponse("contact_form.html", {
"request": request, "sidebar": sidebar,
"page_title": "New Contact", "active_nav": "contacts",
"item": None,
})
@router.post("/create")
async def create_contact(
request: Request,
first_name: str = Form(...),
last_name: Optional[str] = Form(None),
company: Optional[str] = Form(None),
role: Optional[str] = Form(None),
email: Optional[str] = Form(None),
phone: Optional[str] = Form(None),
notes: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("contacts", db)
data = {
"first_name": first_name, "last_name": last_name,
"company": company, "role": role, "email": email,
"phone": phone, "notes": notes,
}
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
await repo.create(data)
return RedirectResponse(url="/contacts", status_code=303)
@router.get("/{contact_id}")
async def contact_detail(contact_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("contacts", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(contact_id)
if not item:
return RedirectResponse(url="/contacts", status_code=303)
return templates.TemplateResponse("contact_detail.html", {
"request": request, "sidebar": sidebar, "item": item,
"page_title": f"{item['first_name']} {item.get('last_name', '')}".strip(),
"active_nav": "contacts",
})
@router.get("/{contact_id}/edit")
async def edit_form(contact_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("contacts", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(contact_id)
if not item:
return RedirectResponse(url="/contacts", status_code=303)
return templates.TemplateResponse("contact_form.html", {
"request": request, "sidebar": sidebar,
"page_title": "Edit Contact", "active_nav": "contacts",
"item": item,
})
@router.post("/{contact_id}/edit")
async def update_contact(
contact_id: str,
first_name: str = Form(...),
last_name: Optional[str] = Form(None),
company: Optional[str] = Form(None),
role: Optional[str] = Form(None),
email: Optional[str] = Form(None),
phone: Optional[str] = Form(None),
notes: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("contacts", db)
data = {
"first_name": first_name, "last_name": last_name,
"company": company, "role": role, "email": email,
"phone": phone, "notes": notes,
}
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
else:
data["tags"] = None
await repo.update(contact_id, data)
return RedirectResponse(url=f"/contacts/{contact_id}", status_code=303)
@router.post("/{contact_id}/delete")
async def delete_contact(contact_id: str, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("contacts", db)
await repo.soft_delete(contact_id)
return RedirectResponse(url="/contacts", status_code=303)

83
routers/domains.py Normal file
View File

@@ -0,0 +1,83 @@
"""Domains: top-level organizational buckets."""
from fastapi import APIRouter, Request, Form, Depends
from fastapi.templating import Jinja2Templates
from fastapi.responses import RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional
from core.database import get_db
from core.base_repository import BaseRepository
from core.sidebar import get_sidebar_data
router = APIRouter(prefix="/domains", tags=["domains"])
templates = Jinja2Templates(directory="templates")
@router.get("/")
async def list_domains(request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("domains", db)
sidebar = await get_sidebar_data(db)
items = await repo.list(sort="sort_order")
return templates.TemplateResponse("domains.html", {
"request": request, "sidebar": sidebar, "items": items,
"page_title": "Domains", "active_nav": "domains",
})
@router.get("/create")
async def create_form(request: Request, db: AsyncSession = Depends(get_db)):
sidebar = await get_sidebar_data(db)
return templates.TemplateResponse("domain_form.html", {
"request": request, "sidebar": sidebar,
"page_title": "New Domain", "active_nav": "domains",
"item": None,
})
@router.post("/create")
async def create_domain(
request: Request,
name: str = Form(...),
color: Optional[str] = Form(None),
description: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("domains", db)
domain = await repo.create({"name": name, "color": color, "description": description})
return RedirectResponse(url="/domains", status_code=303)
@router.get("/{domain_id}/edit")
async def edit_form(domain_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("domains", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(domain_id)
if not item:
return RedirectResponse(url="/domains", status_code=303)
return templates.TemplateResponse("domain_form.html", {
"request": request, "sidebar": sidebar,
"page_title": f"Edit {item['name']}", "active_nav": "domains",
"item": item,
})
@router.post("/{domain_id}/edit")
async def update_domain(
domain_id: str,
request: Request,
name: str = Form(...),
color: Optional[str] = Form(None),
description: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("domains", db)
await repo.update(domain_id, {"name": name, "color": color, "description": description})
return RedirectResponse(url="/domains", status_code=303)
@router.post("/{domain_id}/delete")
async def delete_domain(domain_id: str, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("domains", db)
await repo.soft_delete(domain_id)
return RedirectResponse(url="/domains", status_code=303)

111
routers/focus.py Normal file
View File

@@ -0,0 +1,111 @@
"""Daily Focus: date-scoped task commitment list."""
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 date, datetime, timezone
from core.database import get_db
from core.base_repository import BaseRepository
from core.sidebar import get_sidebar_data
router = APIRouter(prefix="/focus", tags=["focus"])
templates = Jinja2Templates(directory="templates")
@router.get("/")
async def focus_view(request: Request, focus_date: Optional[str] = None, db: AsyncSession = Depends(get_db)):
sidebar = await get_sidebar_data(db)
target_date = focus_date or str(date.today())
result = await db.execute(text("""
SELECT df.*, t.title, t.priority, t.status as task_status,
t.project_id, t.due_date, t.estimated_minutes,
p.name as project_name,
d.name as domain_name, d.color as domain_color
FROM daily_focus df
JOIN tasks t ON df.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 df.focus_date = :target_date AND df.is_deleted = false
ORDER BY df.sort_order, df.created_at
"""), {"target_date": target_date})
items = [dict(r._mapping) for r in result]
# Available tasks to add (open, not already in today's focus)
result = await db.execute(text("""
SELECT t.id, t.title, t.priority, t.due_date,
p.name as project_name, d.name as domain_name
FROM tasks t
LEFT JOIN projects p ON t.project_id = p.id
LEFT JOIN domains d ON t.domain_id = d.id
WHERE t.is_deleted = false AND t.status NOT IN ('done', 'cancelled')
AND t.id NOT IN (
SELECT task_id FROM daily_focus
WHERE focus_date = :target_date AND is_deleted = false
)
ORDER BY t.priority ASC, t.due_date ASC NULLS LAST
LIMIT 50
"""), {"target_date": target_date})
available_tasks = [dict(r._mapping) for r in result]
# Estimated total minutes
total_est = sum(i.get("estimated_minutes") or 0 for i in items)
return templates.TemplateResponse("focus.html", {
"request": request, "sidebar": sidebar,
"items": items, "available_tasks": available_tasks,
"focus_date": target_date,
"total_estimated": total_est,
"page_title": "Daily Focus", "active_nav": "focus",
})
@router.post("/add")
async def add_to_focus(
request: Request,
task_id: str = Form(...),
focus_date: str = Form(...),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("daily_focus", db)
# Get next sort order
result = await db.execute(text("""
SELECT COALESCE(MAX(sort_order), 0) + 10 FROM daily_focus
WHERE focus_date = :fd AND is_deleted = false
"""), {"fd": focus_date})
next_order = result.scalar()
await repo.create({
"task_id": task_id, "focus_date": focus_date,
"sort_order": next_order, "completed": False,
})
return RedirectResponse(url=f"/focus?focus_date={focus_date}", status_code=303)
@router.post("/{focus_id}/toggle")
async def toggle_focus(focus_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("daily_focus", db)
item = await repo.get(focus_id)
if item:
await repo.update(focus_id, {"completed": not item["completed"]})
# Also toggle the task status
task_repo = BaseRepository("tasks", db)
if not item["completed"]:
await task_repo.update(item["task_id"], {"status": "done", "completed_at": datetime.now(timezone.utc)})
else:
await task_repo.update(item["task_id"], {"status": "open", "completed_at": None})
focus_date = item["focus_date"] if item else date.today()
return RedirectResponse(url=f"/focus?focus_date={focus_date}", status_code=303)
@router.post("/{focus_id}/remove")
async def remove_from_focus(focus_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("daily_focus", db)
item = await repo.get(focus_id)
await repo.soft_delete(focus_id)
focus_date = item["focus_date"] if item else date.today()
return RedirectResponse(url=f"/focus?focus_date={focus_date}", status_code=303)

113
routers/links.py Normal file
View File

@@ -0,0 +1,113 @@
"""Links: URL references attached to domains/projects."""
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 core.database import get_db
from core.base_repository import BaseRepository
from core.sidebar import get_sidebar_data
router = APIRouter(prefix="/links", tags=["links"])
templates = Jinja2Templates(directory="templates")
@router.get("/")
async def list_links(request: Request, domain_id: Optional[str] = None, db: AsyncSession = Depends(get_db)):
sidebar = await get_sidebar_data(db)
where_clauses = ["l.is_deleted = false"]
params = {}
if domain_id:
where_clauses.append("l.domain_id = :domain_id")
params["domain_id"] = domain_id
where_sql = " AND ".join(where_clauses)
result = await db.execute(text(f"""
SELECT l.*, d.name as domain_name, d.color as domain_color, p.name as project_name
FROM links l
LEFT JOIN domains d ON l.domain_id = d.id
LEFT JOIN projects p ON l.project_id = p.id
WHERE {where_sql} ORDER BY l.sort_order, l.created_at
"""), params)
items = [dict(r._mapping) for r in result]
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
return templates.TemplateResponse("links.html", {
"request": request, "sidebar": sidebar, "items": items, "domains": domains,
"current_domain_id": domain_id or "",
"page_title": "Links", "active_nav": "links",
})
@router.get("/create")
async def create_form(request: Request, domain_id: Optional[str] = None, project_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()
return templates.TemplateResponse("link_form.html", {
"request": request, "sidebar": sidebar, "domains": domains, "projects": projects,
"page_title": "New Link", "active_nav": "links",
"item": None, "prefill_domain_id": domain_id or "", "prefill_project_id": project_id or "",
})
@router.post("/create")
async def create_link(
request: Request, label: str = Form(...), url: str = Form(...),
domain_id: str = Form(...), project_id: Optional[str] = Form(None),
description: Optional[str] = Form(None), db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("links", db)
data = {"label": label, "url": url, "domain_id": domain_id, "description": description}
if project_id and project_id.strip():
data["project_id"] = project_id
await repo.create(data)
referer = request.headers.get("referer", "/links")
return RedirectResponse(url="/links", status_code=303)
@router.get("/{link_id}/edit")
async def edit_form(link_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("links", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(link_id)
if not item:
return RedirectResponse(url="/links", status_code=303)
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
projects_repo = BaseRepository("projects", db)
projects = await projects_repo.list()
return templates.TemplateResponse("link_form.html", {
"request": request, "sidebar": sidebar, "domains": domains, "projects": projects,
"page_title": "Edit Link", "active_nav": "links",
"item": item, "prefill_domain_id": "", "prefill_project_id": "",
})
@router.post("/{link_id}/edit")
async def update_link(
link_id: str, label: str = Form(...), url: str = Form(...),
domain_id: str = Form(...), project_id: Optional[str] = Form(None),
description: Optional[str] = Form(None), db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("links", db)
await repo.update(link_id, {
"label": label, "url": url, "domain_id": domain_id,
"project_id": project_id if project_id and project_id.strip() else None,
"description": description,
})
return RedirectResponse(url="/links", status_code=303)
@router.post("/{link_id}/delete")
async def delete_link(link_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("links", db)
await repo.soft_delete(link_id)
return RedirectResponse(url=request.headers.get("referer", "/links"), status_code=303)

181
routers/notes.py Normal file
View File

@@ -0,0 +1,181 @@
"""Notes: knowledge documents with project associations."""
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 core.database import get_db
from core.base_repository import BaseRepository
from core.sidebar import get_sidebar_data
router = APIRouter(prefix="/notes", tags=["notes"])
templates = Jinja2Templates(directory="templates")
@router.get("/")
async def list_notes(
request: Request,
domain_id: Optional[str] = None,
project_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
where_clauses = ["n.is_deleted = false"]
params = {}
if domain_id:
where_clauses.append("n.domain_id = :domain_id")
params["domain_id"] = domain_id
if project_id:
where_clauses.append("n.project_id = :project_id")
params["project_id"] = project_id
where_sql = " AND ".join(where_clauses)
result = await db.execute(text(f"""
SELECT n.*, d.name as domain_name, d.color as domain_color,
p.name as project_name
FROM notes n
LEFT JOIN domains d ON n.domain_id = d.id
LEFT JOIN projects p ON n.project_id = p.id
WHERE {where_sql}
ORDER BY n.updated_at DESC
"""), params)
items = [dict(r._mapping) for r in result]
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
return templates.TemplateResponse("notes.html", {
"request": request, "sidebar": sidebar, "items": items,
"domains": domains,
"current_domain_id": domain_id or "",
"current_project_id": project_id or "",
"page_title": "Notes", "active_nav": "notes",
})
@router.get("/create")
async def create_form(
request: Request,
domain_id: Optional[str] = None,
project_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()
return templates.TemplateResponse("note_form.html", {
"request": request, "sidebar": sidebar,
"domains": domains, "projects": projects,
"page_title": "New Note", "active_nav": "notes",
"item": None,
"prefill_domain_id": domain_id or "",
"prefill_project_id": project_id or "",
})
@router.post("/create")
async def create_note(
request: Request,
title: str = Form(...),
domain_id: str = Form(...),
project_id: Optional[str] = Form(None),
body: Optional[str] = Form(None),
content_format: str = Form("rich"),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("notes", db)
data = {
"title": title, "domain_id": domain_id,
"body": body, "content_format": content_format,
}
if project_id and project_id.strip():
data["project_id"] = project_id
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
note = await repo.create(data)
return RedirectResponse(url=f"/notes/{note['id']}", status_code=303)
@router.get("/{note_id}")
async def note_detail(note_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("notes", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(note_id)
if not item:
return RedirectResponse(url="/notes", status_code=303)
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
return templates.TemplateResponse("note_detail.html", {
"request": request, "sidebar": sidebar, "item": item,
"domain": domain, "project": project,
"page_title": item["title"], "active_nav": "notes",
})
@router.get("/{note_id}/edit")
async def edit_form(note_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("notes", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(note_id)
if not item:
return RedirectResponse(url="/notes", status_code=303)
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
projects_repo = BaseRepository("projects", db)
projects = await projects_repo.list()
return templates.TemplateResponse("note_form.html", {
"request": request, "sidebar": sidebar,
"domains": domains, "projects": projects,
"page_title": f"Edit Note", "active_nav": "notes",
"item": item, "prefill_domain_id": "", "prefill_project_id": "",
})
@router.post("/{note_id}/edit")
async def update_note(
note_id: str,
title: str = Form(...),
domain_id: str = Form(...),
project_id: Optional[str] = Form(None),
body: Optional[str] = Form(None),
content_format: str = Form("rich"),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("notes", db)
data = {
"title": title, "domain_id": domain_id, "body": body,
"content_format": content_format,
"project_id": project_id if project_id and project_id.strip() else None,
}
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
else:
data["tags"] = None
await repo.update(note_id, data)
return RedirectResponse(url=f"/notes/{note_id}", status_code=303)
@router.post("/{note_id}/delete")
async def delete_note(note_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("notes", db)
await repo.soft_delete(note_id)
referer = request.headers.get("referer", "/notes")
return RedirectResponse(url=referer, status_code=303)

248
routers/projects.py Normal file
View File

@@ -0,0 +1,248 @@
"""Projects: organizational unit within domain/area 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 core.database import get_db
from core.base_repository import BaseRepository
from core.sidebar import get_sidebar_data
router = APIRouter(prefix="/projects", tags=["projects"])
templates = Jinja2Templates(directory="templates")
@router.get("/")
async def list_projects(
request: Request,
domain_id: Optional[str] = None,
status: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
# Build query with joins for hierarchy display
where_clauses = ["p.is_deleted = false"]
params = {}
if domain_id:
where_clauses.append("p.domain_id = :domain_id")
params["domain_id"] = domain_id
if status:
where_clauses.append("p.status = :status")
params["status"] = status
where_sql = " AND ".join(where_clauses)
result = await db.execute(text(f"""
SELECT p.*,
d.name as domain_name, d.color as domain_color,
a.name as area_name,
(SELECT count(*) FROM tasks t WHERE t.project_id = p.id AND t.is_deleted = false) as task_count,
(SELECT count(*) FROM tasks t WHERE t.project_id = p.id AND t.is_deleted = false AND t.status = 'done') as done_count
FROM projects p
JOIN domains d ON p.domain_id = d.id
LEFT JOIN areas a ON p.area_id = a.id
WHERE {where_sql}
ORDER BY d.sort_order, d.name, a.sort_order, a.name, p.sort_order, p.name
"""), params)
items = [dict(r._mapping) for r in result]
# Calculate progress percentage
for item in items:
total = item["task_count"] or 0
done = item["done_count"] or 0
item["progress"] = round((done / total * 100) if total > 0 else 0)
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
return templates.TemplateResponse("projects.html", {
"request": request, "sidebar": sidebar, "items": items,
"domains": domains,
"current_domain_id": domain_id or "",
"current_status": status or "",
"page_title": "Projects", "active_nav": "projects",
})
@router.get("/create")
async def create_form(
request: Request,
domain_id: Optional[str] = None,
area_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()
areas_repo = BaseRepository("areas", db)
areas = await areas_repo.list()
return templates.TemplateResponse("project_form.html", {
"request": request, "sidebar": sidebar,
"domains": domains, "areas": areas,
"page_title": "New Project", "active_nav": "projects",
"item": None,
"prefill_domain_id": domain_id or "",
"prefill_area_id": area_id or "",
})
@router.post("/create")
async def create_project(
request: Request,
name: str = Form(...),
domain_id: str = Form(...),
area_id: Optional[str] = Form(None),
description: Optional[str] = Form(None),
status: str = Form("active"),
priority: int = Form(3),
start_date: Optional[str] = Form(None),
target_date: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("projects", db)
data = {
"name": name, "domain_id": domain_id,
"description": description, "status": status,
"priority": priority,
}
if area_id and area_id.strip():
data["area_id"] = area_id
if start_date and start_date.strip():
data["start_date"] = start_date
if target_date and target_date.strip():
data["target_date"] = target_date
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
project = await repo.create(data)
return RedirectResponse(url=f"/projects/{project['id']}", status_code=303)
@router.get("/{project_id}")
async def project_detail(
project_id: str,
request: Request,
tab: str = "tasks",
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("projects", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(project_id)
if not item:
return RedirectResponse(url="/projects", status_code=303)
# Get domain and area names
result = await db.execute(text(
"SELECT name, color FROM domains WHERE id = :id"
), {"id": str(item["domain_id"])})
domain = dict(result.first()._mapping) if result else {}
area = None
if item.get("area_id"):
result = await db.execute(text(
"SELECT name FROM areas WHERE id = :id"
), {"id": str(item["area_id"])})
row = result.first()
area = dict(row._mapping) if row else None
# Tasks for this project
result = await db.execute(text("""
SELECT t.*, d.name as domain_name, d.color as domain_color
FROM tasks t
LEFT JOIN domains d ON t.domain_id = d.id
WHERE t.project_id = :pid AND t.is_deleted = false
ORDER BY t.sort_order, t.created_at
"""), {"pid": project_id})
tasks = [dict(r._mapping) for r in result]
# Notes
result = await db.execute(text("""
SELECT * FROM notes WHERE project_id = :pid AND is_deleted = false
ORDER BY sort_order, created_at DESC
"""), {"pid": project_id})
notes = [dict(r._mapping) for r in result]
# Links
result = await db.execute(text("""
SELECT * FROM links WHERE project_id = :pid AND is_deleted = false
ORDER BY sort_order, created_at
"""), {"pid": project_id})
links = [dict(r._mapping) for r in result]
# Progress
total = len(tasks)
done = len([t for t in tasks if t["status"] == "done"])
progress = round((done / total * 100) if total > 0 else 0)
return templates.TemplateResponse("project_detail.html", {
"request": request, "sidebar": sidebar, "item": item,
"domain": domain, "area": area,
"tasks": tasks, "notes": notes, "links": links,
"progress": progress, "task_count": total, "done_count": done,
"tab": tab,
"page_title": item["name"], "active_nav": "projects",
})
@router.get("/{project_id}/edit")
async def edit_form(project_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("projects", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(project_id)
if not item:
return RedirectResponse(url="/projects", status_code=303)
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
areas_repo = BaseRepository("areas", db)
areas = await areas_repo.list()
return templates.TemplateResponse("project_form.html", {
"request": request, "sidebar": sidebar,
"domains": domains, "areas": areas,
"page_title": f"Edit {item['name']}", "active_nav": "projects",
"item": item, "prefill_domain_id": "", "prefill_area_id": "",
})
@router.post("/{project_id}/edit")
async def update_project(
project_id: str,
name: str = Form(...),
domain_id: str = Form(...),
area_id: Optional[str] = Form(None),
description: Optional[str] = Form(None),
status: str = Form("active"),
priority: int = Form(3),
start_date: Optional[str] = Form(None),
target_date: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("projects", db)
data = {
"name": name, "domain_id": domain_id,
"area_id": area_id if area_id and area_id.strip() else None,
"description": description, "status": status,
"priority": priority,
"start_date": start_date if start_date and start_date.strip() else None,
"target_date": target_date if target_date and target_date.strip() else None,
}
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
else:
data["tags"] = None
await repo.update(project_id, data)
return RedirectResponse(url=f"/projects/{project_id}", status_code=303)
@router.post("/{project_id}/delete")
async def delete_project(project_id: str, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("projects", db)
await repo.soft_delete(project_id)
return RedirectResponse(url="/projects", status_code=303)

355
routers/tasks.py Normal file
View File

@@ -0,0 +1,355 @@
"""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")
@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]
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,
"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]
return templates.TemplateResponse("task_detail.html", {
"request": request, "sidebar": sidebar, "item": item,
"domain": domain, "project": project, "parent": parent,
"subtasks": subtasks,
"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, 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)