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

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
.env
.git
__pycache__
*.pyc
.pytest_cache
docker-compose.yml
.dockerignore
.gitignore

10
.env.example Normal file
View File

@@ -0,0 +1,10 @@
# Life OS Environment Configuration
# Copy to .env and fill in values. NEVER commit .env to git.
DATABASE_URL=postgresql+asyncpg://postgres:YOUR_PASSWORD@lifeos-db:5432/lifeos_dev
FILE_STORAGE_PATH=/opt/lifeos/files/dev
ENVIRONMENT=development
# Phase 2
# MCP_API_KEY=your-secret-key
# ANTHROPIC_API_KEY=your-key

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
.env
__pycache__/
*.pyc
.pytest_cache/
*.egg-info/
dist/
build/
.venv/
venv/

22
Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM python:3.12-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application
COPY . .
# Create file storage directory
RUN mkdir -p /opt/lifeos/files
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

0
core/__init__.py Normal file
View File

213
core/base_repository.py Normal file
View File

@@ -0,0 +1,213 @@
from __future__ import annotations
"""
BaseRepository: generic CRUD operations for all entities.
Uses raw SQL via SQLAlchemy text() - no ORM models needed.
Every method automatically filters is_deleted=false unless specified.
"""
from uuid import UUID
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
class BaseRepository:
def __init__(self, table: str, db: AsyncSession):
self.table = table
self.db = db
async def list(
self,
filters: dict | None = None,
sort: str = "sort_order",
sort_dir: str = "ASC",
page: int = 1,
per_page: int = 50,
include_deleted: bool = False,
) -> list[dict]:
"""List rows with optional filtering, sorting, pagination."""
where_clauses = []
params: dict[str, Any] = {}
if not include_deleted:
where_clauses.append("is_deleted = false")
if filters:
for i, (key, value) in enumerate(filters.items()):
if value is None:
where_clauses.append(f"{key} IS NULL")
elif value == "__notnull__":
where_clauses.append(f"{key} IS NOT NULL")
else:
param_name = f"f_{i}"
where_clauses.append(f"{key} = :{param_name}")
params[param_name] = value
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
offset = (page - 1) * per_page
query = text(f"""
SELECT * FROM {self.table}
WHERE {where_sql}
ORDER BY {sort} {sort_dir}
LIMIT :limit OFFSET :offset
""")
params["limit"] = per_page
params["offset"] = offset
result = await self.db.execute(query, params)
return [dict(row._mapping) for row in result]
async def count(
self,
filters: dict | None = None,
include_deleted: bool = False,
) -> int:
"""Count rows matching filters."""
where_clauses = []
params: dict[str, Any] = {}
if not include_deleted:
where_clauses.append("is_deleted = false")
if filters:
for i, (key, value) in enumerate(filters.items()):
if value is None:
where_clauses.append(f"{key} IS NULL")
else:
param_name = f"f_{i}"
where_clauses.append(f"{key} = :{param_name}")
params[param_name] = value
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
query = text(f"SELECT count(*) FROM {self.table} WHERE {where_sql}")
result = await self.db.execute(query, params)
return result.scalar() or 0
async def get(self, id: UUID | str) -> dict | None:
"""Get a single row by ID."""
query = text(f"SELECT * FROM {self.table} WHERE id = :id")
result = await self.db.execute(query, {"id": str(id)})
row = result.first()
return dict(row._mapping) if row else None
async def create(self, data: dict) -> dict:
"""Insert a new row. Auto-sets created_at, updated_at, is_deleted."""
data = {k: v for k, v in data.items() if v is not None or k in ("description", "notes", "body")}
data.setdefault("is_deleted", False)
now = datetime.now(timezone.utc)
if "created_at" not in data:
data["created_at"] = now
if "updated_at" not in data:
data["updated_at"] = now
columns = ", ".join(data.keys())
placeholders = ", ".join(f":{k}" for k in data.keys())
query = text(f"""
INSERT INTO {self.table} ({columns})
VALUES ({placeholders})
RETURNING *
""")
result = await self.db.execute(query, data)
row = result.first()
return dict(row._mapping) if row else data
async def update(self, id: UUID | str, data: dict) -> dict | None:
"""Update a row by ID. Auto-sets updated_at."""
data["updated_at"] = datetime.now(timezone.utc)
# Remove None values except for fields that should be nullable
nullable_fields = {
"description", "notes", "body", "area_id", "project_id",
"parent_id", "release_id", "due_date", "deadline", "tags",
"context", "folder_id", "meeting_id", "completed_at",
"waiting_for_contact_id", "waiting_since", "color",
}
clean_data = {}
for k, v in data.items():
if v is not None or k in nullable_fields:
clean_data[k] = v
if not clean_data:
return await self.get(id)
set_clauses = ", ".join(f"{k} = :{k}" for k in clean_data.keys())
clean_data["id"] = str(id)
query = text(f"""
UPDATE {self.table}
SET {set_clauses}
WHERE id = :id
RETURNING *
""")
result = await self.db.execute(query, clean_data)
row = result.first()
return dict(row._mapping) if row else None
async def soft_delete(self, id: UUID | str) -> bool:
"""Soft delete: set is_deleted=true, deleted_at=now()."""
query = text(f"""
UPDATE {self.table}
SET is_deleted = true, deleted_at = :now, updated_at = :now
WHERE id = :id AND is_deleted = false
RETURNING id
""")
now = datetime.now(timezone.utc)
result = await self.db.execute(query, {"id": str(id), "now": now})
return result.first() is not None
async def restore(self, id: UUID | str) -> bool:
"""Restore a soft-deleted row."""
query = text(f"""
UPDATE {self.table}
SET is_deleted = false, deleted_at = NULL, updated_at = :now
WHERE id = :id AND is_deleted = true
RETURNING id
""")
now = datetime.now(timezone.utc)
result = await self.db.execute(query, {"id": str(id), "now": now})
return result.first() is not None
async def permanent_delete(self, id: UUID | str) -> bool:
"""Hard delete. Admin only."""
query = text(f"DELETE FROM {self.table} WHERE id = :id RETURNING id")
result = await self.db.execute(query, {"id": str(id)})
return result.first() is not None
async def bulk_soft_delete(self, ids: list[str]) -> int:
"""Soft delete multiple rows."""
if not ids:
return 0
now = datetime.now(timezone.utc)
placeholders = ", ".join(f":id_{i}" for i in range(len(ids)))
params = {f"id_{i}": str(id) for i, id in enumerate(ids)}
params["now"] = now
query = text(f"""
UPDATE {self.table}
SET is_deleted = true, deleted_at = :now, updated_at = :now
WHERE id IN ({placeholders}) AND is_deleted = false
""")
result = await self.db.execute(query, params)
return result.rowcount
async def list_deleted(self) -> list[dict]:
"""List all soft-deleted rows. Used by Admin > Trash."""
query = text(f"""
SELECT * FROM {self.table}
WHERE is_deleted = true
ORDER BY deleted_at DESC
""")
result = await self.db.execute(query)
return [dict(row._mapping) for row in result]
async def reorder(self, id_order: list[str]) -> None:
"""Update sort_order based on position in list."""
for i, id in enumerate(id_order):
await self.db.execute(
text(f"UPDATE {self.table} SET sort_order = :order WHERE id = :id"),
{"order": (i + 1) * 10, "id": str(id)}
)

47
core/database.py Normal file
View File

@@ -0,0 +1,47 @@
"""
Database connection and session management.
Async SQLAlchemy 2.0 with asyncpg driver.
"""
import os
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy import text
DATABASE_URL = os.getenv(
"DATABASE_URL",
"postgresql+asyncpg://postgres:postgres@lifeos-db:5432/lifeos_dev"
)
engine = create_async_engine(
DATABASE_URL,
echo=os.getenv("ENVIRONMENT") == "development",
pool_size=5,
max_overflow=10,
pool_pre_ping=True,
)
async_session_factory = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
async def get_db():
"""FastAPI dependency: yields an async database session."""
async with async_session_factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def check_db():
"""Health check: verify database connectivity."""
async with async_session_factory() as session:
result = await session.execute(text("SELECT 1"))
return result.scalar() == 1

72
core/sidebar.py Normal file
View File

@@ -0,0 +1,72 @@
"""
Sidebar navigation data builder.
Loads domains > areas > projects hierarchy for the sidebar tree.
"""
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
async def get_sidebar_data(db: AsyncSession) -> dict:
"""Build full sidebar navigation data."""
# Domains
result = await db.execute(text("""
SELECT id, name, color FROM domains
WHERE is_deleted = false ORDER BY sort_order, name
"""))
domains = [dict(r._mapping) for r in result]
# Areas grouped by domain
result = await db.execute(text("""
SELECT id, domain_id, name FROM areas
WHERE is_deleted = false ORDER BY sort_order, name
"""))
areas = [dict(r._mapping) for r in result]
# Projects grouped by domain/area
result = await db.execute(text("""
SELECT id, domain_id, area_id, name, status FROM projects
WHERE is_deleted = false AND status != 'archived'
ORDER BY sort_order, name
"""))
projects = [dict(r._mapping) for r in result]
# Counts for badges
result = await db.execute(text("""
SELECT count(*) FROM capture WHERE is_deleted = false AND processed = false
"""))
capture_count = result.scalar() or 0
result = await db.execute(text("""
SELECT count(*) FROM daily_focus
WHERE is_deleted = false AND focus_date = CURRENT_DATE AND completed = false
"""))
focus_count = result.scalar() or 0
# Build tree structure
domain_tree = []
for d in domains:
d_areas = [a for a in areas if str(a["domain_id"]) == str(d["id"])]
d_projects = [p for p in projects if str(p["domain_id"]) == str(d["id"])]
# Projects under areas
for a in d_areas:
a["projects"] = [p for p in d_projects if str(p.get("area_id", "")) == str(a["id"])]
# Projects directly under domain (no area)
standalone_projects = [p for p in d_projects if p.get("area_id") is None]
domain_tree.append({
"id": d["id"],
"name": d["name"],
"color": d.get("color", "#4F6EF7"),
"areas": d_areas,
"standalone_projects": standalone_projects,
})
return {
"domain_tree": domain_tree,
"capture_count": capture_count,
"focus_count": focus_count,
}

47
docker-compose.yml Normal file
View File

@@ -0,0 +1,47 @@
version: '3.9'
services:
lifeos-prod:
build:
context: .
dockerfile: Dockerfile
container_name: lifeos-prod
restart: unless-stopped
environment:
DATABASE_URL: postgresql+asyncpg://postgres:${DB_PASSWORD}@lifeos-db:5432/lifeos_prod
FILE_STORAGE_PATH: /opt/lifeos/files/prod
ENVIRONMENT: production
command: uvicorn main:app --host 0.0.0.0 --port 8002 --workers 1
ports:
- "8002:8002"
volumes:
- /opt/lifeos/prod/files:/opt/lifeos/files/prod
networks:
- lifeos_network
depends_on:
- lifeos-db
lifeos-dev:
build:
context: .
dockerfile: Dockerfile
container_name: lifeos-dev
restart: unless-stopped
environment:
DATABASE_URL: postgresql+asyncpg://postgres:${DB_PASSWORD}@lifeos-db:5432/lifeos_dev
FILE_STORAGE_PATH: /opt/lifeos/files/dev
ENVIRONMENT: development
command: uvicorn main:app --host 0.0.0.0 --port 8003 --workers 1 --reload
ports:
- "8003:8003"
volumes:
- /opt/lifeos/dev/files:/opt/lifeos/files/dev
- .:/app # hot reload in dev
networks:
- lifeos_network
depends_on:
- lifeos-db
networks:
lifeos_network:
external: true

169
main.py Normal file
View File

@@ -0,0 +1,169 @@
"""
Life OS - Main Application
FastAPI server-rendered monolith with async PostgreSQL.
"""
import os
from pathlib import Path
from contextlib import asynccontextmanager
from dotenv import load_dotenv
load_dotenv()
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.responses import RedirectResponse
from core.database import check_db
from core.sidebar import get_sidebar_data
from core.database import get_db
# Import routers
from routers import (
domains as domains_router,
areas as areas_router,
projects as projects_router,
tasks as tasks_router,
notes as notes_router,
links as links_router,
focus as focus_router,
capture as capture_router,
contacts as contacts_router,
)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup/shutdown events."""
# Verify database connection
try:
ok = await check_db()
if ok:
print("Database connection OK")
else:
print("WARNING: Database check returned unexpected result")
except Exception as e:
print(f"WARNING: Database connection failed: {e}")
yield
app = FastAPI(
title="Life OS",
version="1.0.0",
lifespan=lifespan,
)
# Static files
app.mount("/static", StaticFiles(directory="static"), name="static")
# Templates
templates = Jinja2Templates(directory="templates")
# ---- Template globals and filters ----
@app.middleware("http")
async def add_request_context(request: Request, call_next):
"""Make environment available to all templates."""
request.state.environment = os.getenv("ENVIRONMENT", "production")
response = await call_next(request)
return response
# ---- Dashboard ----
@app.get("/")
async def dashboard(request: Request):
"""Main dashboard view."""
from core.database import async_session_factory
async with async_session_factory() as db:
sidebar = await get_sidebar_data(db)
# Today's focus items
from sqlalchemy import text
result = await db.execute(text("""
SELECT df.*, t.title, t.priority, t.status as task_status,
t.project_id, 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 = CURRENT_DATE AND df.is_deleted = false
ORDER BY df.sort_order, df.created_at
"""))
focus_items = [dict(r._mapping) for r in result]
# Overdue tasks
result = await db.execute(text("""
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 t.is_deleted = false AND t.status NOT IN ('done', 'cancelled')
AND t.due_date < CURRENT_DATE
ORDER BY t.due_date ASC
LIMIT 10
"""))
overdue_tasks = [dict(r._mapping) for r in result]
# Upcoming deadlines (next 7 days)
result = await db.execute(text("""
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 t.is_deleted = false AND t.status NOT IN ('done', 'cancelled')
AND t.due_date >= CURRENT_DATE AND t.due_date <= CURRENT_DATE + INTERVAL '7 days'
ORDER BY t.due_date ASC
LIMIT 10
"""))
upcoming_tasks = [dict(r._mapping) for r in result]
# Task stats
result = await db.execute(text("""
SELECT
count(*) FILTER (WHERE status NOT IN ('done', 'cancelled')) as open_tasks,
count(*) FILTER (WHERE status = 'done' AND completed_at >= CURRENT_DATE - INTERVAL '7 days') as done_this_week,
count(*) FILTER (WHERE status = 'in_progress') as in_progress
FROM tasks WHERE is_deleted = false
"""))
stats = dict(result.first()._mapping)
return templates.TemplateResponse("dashboard.html", {
"request": request,
"sidebar": sidebar,
"focus_items": focus_items,
"overdue_tasks": overdue_tasks,
"upcoming_tasks": upcoming_tasks,
"stats": stats,
"page_title": "Dashboard",
"active_nav": "dashboard",
})
# ---- Health check ----
@app.get("/health")
async def health():
try:
ok = await check_db()
return {"status": "ok" if ok else "degraded", "database": ok}
except Exception as e:
return {"status": "error", "database": False, "error": str(e)}
# ---- Include routers ----
app.include_router(domains_router.router)
app.include_router(areas_router.router)
app.include_router(projects_router.router)
app.include_router(tasks_router.router)
app.include_router(notes_router.router)
app.include_router(links_router.router)
app.include_router(focus_router.router)
app.include_router(capture_router.router)
app.include_router(contacts_router.router)

10
requirements.txt Normal file
View File

@@ -0,0 +1,10 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy[asyncio]==2.0.36
asyncpg==0.30.0
jinja2==3.1.4
python-multipart==0.0.18
python-dotenv==1.0.1
pydantic==2.10.3
pyyaml==6.0.2
aiofiles==24.1.0

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)

56
static/app.js Normal file
View File

@@ -0,0 +1,56 @@
/* Life OS - UI Interactions */
document.addEventListener('DOMContentLoaded', () => {
// Theme toggle
const theme = localStorage.getItem('lifeos-theme') || 'dark';
document.documentElement.setAttribute('data-theme', theme);
// Domain tree collapse
document.querySelectorAll('.domain-header').forEach(header => {
header.addEventListener('click', () => {
const children = header.nextElementSibling;
if (children && children.classList.contains('domain-children')) {
children.classList.toggle('collapsed');
const key = 'sidebar-' + header.dataset.domainId;
localStorage.setItem(key, children.classList.contains('collapsed'));
}
});
});
// Restore collapsed state
document.querySelectorAll('.domain-children').forEach(el => {
const key = 'sidebar-' + el.dataset.domainId;
if (localStorage.getItem(key) === 'true') {
el.classList.add('collapsed');
}
});
// Auto-submit filters on change
document.querySelectorAll('.filter-select[data-auto-submit]').forEach(select => {
select.addEventListener('change', () => {
select.closest('form').submit();
});
});
// Confirm delete dialogs
document.querySelectorAll('form[data-confirm]').forEach(form => {
form.addEventListener('submit', (e) => {
if (!confirm(form.dataset.confirm)) {
e.preventDefault();
}
});
});
});
// Theme toggle function
function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('lifeos-theme', next);
}
// Collapsed style
document.head.insertAdjacentHTML('beforeend',
'<style>.domain-children.collapsed { display: none; }</style>'
);

845
static/style.css Normal file
View File

@@ -0,0 +1,845 @@
/* ==========================================================================
Life OS - Stylesheet
Dark-first design with light theme support.
Design tokens from architecture spec Section 9.2.
========================================================================== */
/* ---- Reset ---- */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* ---- Theme Tokens ---- */
[data-theme="dark"] {
--bg: #0D0E13;
--surface: #14161F;
--surface2: #1A1D28;
--surface3: #222533;
--border: #252836;
--border-light: #2E3244;
--text: #DDE1F5;
--text-secondary: #9CA3C4;
--muted: #5A6080;
--accent: #4F6EF7;
--accent-hover: #6380FF;
--accent-soft: rgba(79,110,247,.12);
--green: #22C98A;
--green-soft: rgba(34,201,138,.12);
--amber: #F5A623;
--amber-soft: rgba(245,166,35,.12);
--red: #F05252;
--red-soft: rgba(240,82,82,.12);
--purple: #9B7FF5;
--purple-soft: rgba(155,127,245,.12);
--shadow: 0 2px 8px rgba(0,0,0,.3);
--shadow-lg: 0 8px 32px rgba(0,0,0,.4);
}
[data-theme="light"] {
--bg: #F0F2F8;
--surface: #FFFFFF;
--surface2: #F7F8FC;
--surface3: #EEF0F6;
--border: #E3E6F0;
--border-light: #ECEEF5;
--text: #171926;
--text-secondary: #555B78;
--muted: #8892B0;
--accent: #4F6EF7;
--accent-hover: #3A5BE0;
--accent-soft: rgba(79,110,247,.10);
--green: #10B981;
--green-soft: rgba(16,185,129,.10);
--amber: #F59E0B;
--amber-soft: rgba(245,158,11,.10);
--red: #DC2626;
--red-soft: rgba(220,38,38,.10);
--purple: #7C3AED;
--purple-soft: rgba(124,58,237,.10);
--shadow: 0 2px 8px rgba(0,0,0,.06);
--shadow-lg: 0 8px 32px rgba(0,0,0,.1);
}
/* ---- Typography ---- */
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=JetBrains+Mono:wght@400;500&display=swap');
:root {
--font-body: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
--radius: 8px;
--radius-sm: 4px;
--radius-lg: 12px;
--sidebar-width: 260px;
--topbar-height: 52px;
--transition: 150ms ease;
}
html {
font-size: 14px;
-webkit-font-smoothing: antialiased;
}
body {
font-family: var(--font-body);
background: var(--bg);
color: var(--text);
line-height: 1.5;
min-height: 100vh;
}
a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-hover); }
/* ---- Layout Shell ---- */
.app-layout {
display: flex;
min-height: 100vh;
}
/* ---- Sidebar ---- */
.sidebar {
width: var(--sidebar-width);
background: var(--surface);
border-right: 1px solid var(--border);
height: 100vh;
position: fixed;
top: 0;
left: 0;
overflow-y: auto;
overflow-x: hidden;
z-index: 100;
display: flex;
flex-direction: column;
}
.sidebar-header {
padding: 16px 16px 12px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 10px;
}
.sidebar-logo {
font-size: 1.15rem;
font-weight: 700;
color: var(--text);
letter-spacing: -0.02em;
}
.sidebar-logo span {
color: var(--accent);
}
.sidebar-nav {
flex: 1;
padding: 8px 0;
overflow-y: auto;
}
.nav-section {
padding: 0 8px;
margin-bottom: 4px;
}
.nav-section-label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
padding: 12px 8px 4px;
}
.nav-item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-size: 0.92rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition);
}
.nav-item:hover {
background: var(--accent-soft);
color: var(--text);
}
.nav-item.active {
background: var(--accent-soft);
color: var(--accent);
}
.nav-item .badge {
margin-left: auto;
background: var(--accent);
color: #fff;
font-size: 0.7rem;
font-weight: 600;
padding: 1px 6px;
border-radius: 10px;
min-width: 18px;
text-align: center;
}
.nav-icon {
width: 18px;
height: 18px;
opacity: 0.7;
flex-shrink: 0;
}
.nav-item.active .nav-icon { opacity: 1; }
/* Domain tree */
.domain-group { margin-top: 4px; }
.domain-header {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
font-size: 0.85rem;
font-weight: 600;
color: var(--text);
cursor: pointer;
border-radius: var(--radius-sm);
}
.domain-header:hover { background: var(--surface2); }
.domain-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.domain-children {
padding-left: 12px;
}
.area-label {
font-size: 0.78rem;
font-weight: 600;
color: var(--muted);
padding: 6px 10px 2px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.project-link {
display: block;
padding: 4px 10px 4px 18px;
font-size: 0.85rem;
color: var(--text-secondary);
border-radius: var(--radius-sm);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.project-link:hover {
background: var(--surface2);
color: var(--text);
}
/* ---- Main Content ---- */
.main-content {
margin-left: var(--sidebar-width);
flex: 1;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.topbar {
height: var(--topbar-height);
background: var(--surface);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 24px;
gap: 16px;
position: sticky;
top: 0;
z-index: 50;
}
.topbar-env {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
background: var(--amber-soft);
color: var(--amber);
padding: 2px 8px;
border-radius: var(--radius-sm);
}
.topbar-spacer { flex: 1; }
.page-content {
flex: 1;
padding: 24px;
max-width: 1200px;
width: 100%;
}
/* ---- Page Header ---- */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
gap: 16px;
}
.page-title {
font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.page-count {
font-size: 0.85rem;
color: var(--muted);
margin-left: 8px;
font-weight: 400;
}
/* ---- Breadcrumb ---- */
.breadcrumb {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.82rem;
color: var(--muted);
margin-bottom: 12px;
}
.breadcrumb a { color: var(--text-secondary); }
.breadcrumb a:hover { color: var(--accent); }
.breadcrumb .sep { color: var(--border); }
/* ---- Buttons ---- */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: var(--radius);
font-family: var(--font-body);
font-size: 0.85rem;
font-weight: 600;
border: none;
cursor: pointer;
transition: all var(--transition);
text-decoration: none;
line-height: 1.4;
}
.btn-primary {
background: var(--accent);
color: #fff;
}
.btn-primary:hover { background: var(--accent-hover); color: #fff; }
.btn-secondary {
background: var(--surface2);
color: var(--text-secondary);
border: 1px solid var(--border);
}
.btn-secondary:hover { background: var(--surface3); color: var(--text); }
.btn-danger {
background: var(--red-soft);
color: var(--red);
}
.btn-danger:hover { background: var(--red); color: #fff; }
.btn-ghost {
background: transparent;
color: var(--text-secondary);
padding: 6px 10px;
}
.btn-ghost:hover { background: var(--surface2); color: var(--text); }
.btn-sm { padding: 5px 10px; font-size: 0.8rem; }
.btn-xs { padding: 3px 8px; font-size: 0.75rem; }
/* ---- Cards ---- */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 20px;
box-shadow: var(--shadow);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.card-title {
font-size: 1rem;
font-weight: 600;
}
/* ---- Tables / List Rows ---- */
.list-table {
width: 100%;
border-collapse: collapse;
}
.list-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
transition: background var(--transition);
}
.list-row:hover {
background: var(--surface2);
}
.list-row.completed .row-title {
text-decoration: line-through;
color: var(--muted);
}
.row-check {
flex-shrink: 0;
}
.row-check input[type="checkbox"] {
display: none;
}
.row-check label {
width: 20px;
height: 20px;
border: 2px solid var(--border-light);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all var(--transition);
}
.row-check label:hover {
border-color: var(--accent);
}
.row-check input[type="checkbox"]:checked + label {
background: var(--green);
border-color: var(--green);
}
.row-check input[type="checkbox"]:checked + label::after {
content: "\2713";
color: #fff;
font-size: 0.75rem;
font-weight: 700;
}
.priority-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.priority-1 { background: var(--red); }
.priority-2 { background: var(--amber); }
.priority-3 { background: var(--accent); }
.priority-4 { background: var(--muted); }
.row-title {
flex: 1;
font-weight: 500;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-title a { color: var(--text); }
.row-title a:hover { color: var(--accent); }
.row-meta {
font-size: 0.78rem;
color: var(--muted);
white-space: nowrap;
}
.row-tag {
font-size: 0.72rem;
background: var(--surface2);
color: var(--text-secondary);
padding: 2px 8px;
border-radius: 10px;
border: 1px solid var(--border);
}
.row-domain-tag {
font-size: 0.72rem;
padding: 2px 8px;
border-radius: 10px;
font-weight: 500;
}
.row-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity var(--transition);
}
.list-row:hover .row-actions { opacity: 1; }
.overdue { color: var(--red) !important; font-weight: 600; }
/* ---- Filters Bar ---- */
.filters-bar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
align-items: center;
}
.filter-select {
font-family: var(--font-body);
font-size: 0.82rem;
padding: 6px 10px;
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
}
.filter-select:focus {
outline: none;
border-color: var(--accent);
}
/* ---- Forms ---- */
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.form-group.full-width {
grid-column: 1 / -1;
}
.form-label {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.form-input,
.form-select,
.form-textarea {
font-family: var(--font-body);
font-size: 0.92rem;
padding: 9px 12px;
background: var(--surface2);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
transition: border-color var(--transition);
}
.form-input:focus,
.form-select:focus,
.form-textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.form-textarea {
min-height: 120px;
resize: vertical;
}
.form-actions {
display: flex;
gap: 10px;
margin-top: 8px;
grid-column: 1 / -1;
}
/* ---- Quick Add ---- */
.quick-add {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.quick-add input {
flex: 1;
font-family: var(--font-body);
font-size: 0.92rem;
padding: 9px 12px;
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.quick-add input:focus {
outline: none;
border-color: var(--accent);
}
.quick-add input::placeholder { color: var(--muted); }
/* ---- Progress Bar ---- */
.progress-bar {
height: 6px;
background: var(--surface2);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--green);
border-radius: 3px;
transition: width 300ms ease;
}
.progress-text {
font-size: 0.78rem;
color: var(--muted);
margin-top: 2px;
}
/* ---- Tabs ---- */
.tab-strip {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border);
margin-bottom: 20px;
}
.tab-item {
padding: 10px 20px;
font-size: 0.88rem;
font-weight: 500;
color: var(--muted);
border-bottom: 2px solid transparent;
cursor: pointer;
transition: all var(--transition);
text-decoration: none;
}
.tab-item:hover { color: var(--text); }
.tab-item.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
/* ---- Status Badges ---- */
.status-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.75rem;
font-weight: 600;
padding: 3px 10px;
border-radius: 10px;
text-transform: capitalize;
}
.status-open { background: var(--accent-soft); color: var(--accent); }
.status-active { background: var(--green-soft); color: var(--green); }
.status-in_progress { background: var(--amber-soft); color: var(--amber); }
.status-blocked { background: var(--red-soft); color: var(--red); }
.status-done { background: var(--green-soft); color: var(--green); }
.status-completed { background: var(--green-soft); color: var(--green); }
.status-cancelled { background: var(--surface2); color: var(--muted); }
.status-on_hold { background: var(--purple-soft); color: var(--purple); }
.status-archived { background: var(--surface2); color: var(--muted); }
/* ---- Empty State ---- */
.empty-state {
text-align: center;
padding: 48px 24px;
color: var(--muted);
}
.empty-state-icon {
font-size: 2.5rem;
margin-bottom: 12px;
opacity: 0.4;
}
.empty-state-text {
font-size: 0.95rem;
margin-bottom: 16px;
}
/* ---- Detail Header ---- */
.detail-header {
margin-bottom: 24px;
}
.detail-title {
font-size: 1.75rem;
font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: 8px;
}
.detail-meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
font-size: 0.85rem;
color: var(--text-secondary);
}
.detail-meta-item {
display: flex;
align-items: center;
gap: 4px;
}
.detail-body {
line-height: 1.75;
color: var(--text);
}
.detail-body p { margin-bottom: 1em; }
.detail-body h1, .detail-body h2, .detail-body h3 { margin: 1.2em 0 0.6em; font-weight: 600; }
.detail-body code { font-family: var(--font-mono); background: var(--surface2); padding: 1px 5px; border-radius: 3px; }
.detail-body pre { background: var(--surface2); padding: 16px; border-radius: var(--radius); overflow-x: auto; margin: 1em 0; }
.detail-body pre code { background: none; padding: 0; }
/* ---- Dashboard Widgets ---- */
.dashboard-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.stat-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 20px;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.stat-label {
font-size: 0.82rem;
color: var(--muted);
margin-top: 2px;
}
/* ---- Capture Items ---- */
.capture-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 8px;
}
.capture-text {
flex: 1;
font-size: 0.92rem;
}
.capture-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
/* ---- Focus Items ---- */
.focus-item {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 6px;
transition: all var(--transition);
}
.focus-item:hover { border-color: var(--accent); }
.focus-item.completed { opacity: 0.6; }
.focus-item.completed .focus-title { text-decoration: line-through; }
.focus-title { flex: 1; font-weight: 500; }
.focus-meta { font-size: 0.78rem; color: var(--muted); }
/* ---- Utility ---- */
.text-muted { color: var(--muted); }
.text-sm { font-size: 0.82rem; }
.text-xs { font-size: 0.75rem; }
.mt-1 { margin-top: 4px; }
.mt-2 { margin-top: 8px; }
.mt-3 { margin-top: 16px; }
.mt-4 { margin-top: 24px; }
.mb-2 { margin-bottom: 8px; }
.mb-3 { margin-bottom: 16px; }
.mb-4 { margin-bottom: 24px; }
.gap-2 { gap: 8px; }
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.flex-1 { flex: 1; }
.hidden { display: none; }
/* ---- Scrollbar ---- */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--muted); }
/* ---- Responsive ---- */
@media (max-width: 768px) {
.sidebar { display: none; }
.main-content { margin-left: 0; }
.form-grid { grid-template-columns: 1fr; }
.dashboard-grid { grid-template-columns: 1fr; }
.page-content { padding: 16px; }
}

22
templates/area_form.html Normal file
View File

@@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block content %}
<div class="breadcrumb"><a href="/areas">Areas</a><span class="sep">/</span><span>{{ 'Edit' if item else 'New Area' }}</span></div>
<div class="page-header"><h1 class="page-title">{{ 'Edit Area' if item else 'New Area' }}</h1></div>
<div class="card">
<form method="post" action="{{ '/areas/' ~ item.id ~ '/edit' if item else '/areas/create' }}">
<div class="form-grid">
<div class="form-group"><label class="form-label">Name *</label><input type="text" name="name" class="form-input" required value="{{ item.name if item else '' }}"></div>
<div class="form-group"><label class="form-label">Domain *</label>
<select name="domain_id" class="form-select" required>
{% for d in domains %}<option value="{{ d.id }}" {{ 'selected' if (item and item.domain_id|string == d.id|string) or (not item and prefill_domain_id == d.id|string) }}>{{ d.name }}</option>{% endfor %}
</select></div>
<div class="form-group"><label class="form-label">Status</label>
<select name="status" class="form-select">
<option value="active" {{ 'selected' if not item or item.status == 'active' }}>Active</option>
<option value="inactive" {{ 'selected' if item and item.status == 'inactive' }}>Inactive</option>
</select></div>
<div class="form-group full-width"><label class="form-label">Description</label><textarea name="description" class="form-textarea" rows="3">{{ item.description if item and item.description else '' }}</textarea></div>
<div class="form-actions"><button type="submit" class="btn btn-primary">{{ 'Save' if item else 'Create' }}</button><a href="/areas" class="btn btn-secondary">Cancel</a></div>
</div>
</form></div>
{% endblock %}

25
templates/areas.html Normal file
View File

@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Areas<span class="page-count">{{ items|length }}</span></h1>
<a href="/areas/create" class="btn btn-primary">+ New Area</a>
</div>
{% if items %}
<div class="card">
{% for item in items %}
<div class="list-row">
<span class="row-domain-tag" style="background:{{ item.domain_color or '#4F6EF7' }}22;color:{{ item.domain_color or '#4F6EF7' }}">{{ item.domain_name }}</span>
<span class="row-title">{{ item.name }}</span>
{% if item.description %}<span class="row-meta">{{ item.description[:60] }}</span>{% endif %}
<span class="status-badge status-{{ item.status }}">{{ item.status }}</span>
<div class="row-actions">
<a href="/areas/{{ item.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
<form action="/areas/{{ item.id }}/delete" method="post" data-confirm="Delete this area?" style="display:inline"><button class="btn btn-ghost btn-xs" style="color:var(--red)">Del</button></form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state"><div class="empty-state-text">No areas yet</div><a href="/areas/create" class="btn btn-primary">Create Area</a></div>
{% endif %}
{% endblock %}

111
templates/base.html Normal file
View File

@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ page_title or "Life OS" }} - Life OS</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="app-layout">
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-header">
<a href="/" class="sidebar-logo">Life<span>OS</span></a>
</div>
<nav class="sidebar-nav">
<div class="nav-section">
<a href="/" class="nav-item {{ 'active' if active_nav == 'dashboard' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
Dashboard
</a>
<a href="/focus" class="nav-item {{ 'active' if active_nav == 'focus' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>
Focus
{% if sidebar.focus_count %}<span class="badge">{{ sidebar.focus_count }}</span>{% endif %}
</a>
<a href="/tasks" class="nav-item {{ 'active' if active_nav == 'tasks' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
All Tasks
</a>
<a href="/projects" class="nav-item {{ 'active' if active_nav == 'projects' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
Projects
</a>
<a href="/notes" class="nav-item {{ 'active' if active_nav == 'notes' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>
Notes
</a>
<a href="/contacts" class="nav-item {{ 'active' if active_nav == 'contacts' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
Contacts
</a>
<a href="/links" class="nav-item {{ 'active' if active_nav == 'links' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
Links
</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
{% if sidebar.capture_count %}<span class="badge">{{ sidebar.capture_count }}</span>{% endif %}
</a>
</div>
<!-- Domain hierarchy -->
<div class="nav-section">
<div class="nav-section-label">Domains</div>
{% for domain in sidebar.domain_tree %}
<div class="domain-group">
<div class="domain-header" data-domain-id="{{ domain.id }}">
<span class="domain-dot" style="background: {{ domain.color or '#4F6EF7' }}"></span>
{{ domain.name }}
</div>
<div class="domain-children" data-domain-id="{{ domain.id }}">
{% for area in domain.areas %}
<div class="area-label">{{ area.name }}</div>
{% for p in area.projects %}
<a href="/projects/{{ p.id }}" class="project-link">{{ p.name }}</a>
{% endfor %}
{% endfor %}
{% for p in domain.standalone_projects %}
<a href="/projects/{{ p.id }}" class="project-link">{{ p.name }}</a>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
<div class="nav-section" style="margin-top: auto; padding-bottom: 12px;">
<a href="/domains" class="nav-item {{ 'active' if active_nav == 'domains' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v6m0 6v6m-7-7h6m6 0h6"/></svg>
Manage Domains
</a>
<a href="/areas" class="nav-item {{ 'active' if active_nav == 'areas' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg>
Manage Areas
</a>
<div class="nav-item" onclick="toggleTheme()" style="cursor: pointer;">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
Toggle Theme
</div>
</div>
</nav>
</aside>
<!-- Main content -->
<main class="main-content">
<header class="topbar">
{% if request.state.environment == 'development' %}
<span class="topbar-env">DEV</span>
{% endif %}
<div class="topbar-spacer"></div>
</header>
<div class="page-content">
{% block content %}{% endblock %}
</div>
</main>
</div>
<script src="/static/app.js"></script>
</body>
</html>

42
templates/capture.html Normal file
View File

@@ -0,0 +1,42 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Capture<span class="page-count">{{ items|length }}</span></h1>
</div>
<!-- Quick capture input -->
<div class="card mb-4">
<form action="/capture/add" method="post">
<label class="form-label mb-2">Quick Capture (one item per line)</label>
<textarea name="raw_text" class="form-textarea" rows="3" placeholder="Type or paste items here...&#10;Each line becomes a separate capture item"></textarea>
<div class="mt-2"><button type="submit" class="btn btn-primary">Capture</button></div>
</form>
</div>
<div class="flex items-center gap-2 mb-3">
<a href="/capture?show=unprocessed" class="btn {{ 'btn-primary' if show != 'all' else 'btn-secondary' }} btn-sm">Unprocessed</a>
<a href="/capture?show=all" class="btn {{ 'btn-primary' if show == 'all' else 'btn-secondary' }} btn-sm">All</a>
</div>
{% if items %}
{% for item in items %}
<div class="capture-item {{ 'completed' if item.processed }}">
<div class="capture-text {{ 'text-muted' if item.processed }}" style="{{ 'text-decoration:line-through' if item.processed }}">{{ item.raw_text }}</div>
{% if not item.processed %}
<div class="capture-actions">
<form action="/capture/{{ item.id }}/to-task" method="post" class="flex gap-2">
<select name="domain_id" class="filter-select" style="font-size:0.75rem;padding:3px 6px" required>
{% for d in sidebar.domain_tree %}<option value="{{ d.id }}">{{ d.name }}</option>{% endfor %}
</select>
<button type="submit" class="btn btn-ghost btn-xs" style="color:var(--green)">To Task</button>
</form>
<form action="/capture/{{ item.id }}/dismiss" method="post" style="display:inline"><button class="btn btn-ghost btn-xs">Dismiss</button></form>
<form action="/capture/{{ item.id }}/delete" method="post" style="display:inline"><button class="btn btn-ghost btn-xs" style="color:var(--red)">&times;</button></form>
</div>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="empty-state"><div class="empty-state-icon">&#128229;</div><div class="empty-state-text">Capture queue is empty</div></div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block content %}
<div class="breadcrumb"><a href="/contacts">Contacts</a><span class="sep">/</span><span>{{ item.first_name }} {{ item.last_name or '' }}</span></div>
<div class="detail-header">
<div class="flex items-center justify-between">
<h1 class="detail-title">{{ item.first_name }} {{ item.last_name or '' }}</h1>
<div class="flex gap-2">
<a href="/contacts/{{ item.id }}/edit" class="btn btn-secondary btn-sm">Edit</a>
<form action="/contacts/{{ item.id }}/delete" method="post" data-confirm="Delete?" style="display:inline"><button class="btn btn-danger btn-sm">Delete</button></form>
</div>
</div>
<div class="detail-meta mt-2">
{% if item.company %}<span class="row-tag">{{ item.company }}</span>{% endif %}
{% if item.role %}<span class="detail-meta-item">{{ item.role }}</span>{% endif %}
</div>
</div>
<div class="card">
<div class="form-grid" style="gap:12px">
{% if item.email %}<div class="form-group"><div class="form-label">Email</div><a href="mailto:{{ item.email }}">{{ item.email }}</a></div>{% endif %}
{% if item.phone %}<div class="form-group"><div class="form-label">Phone</div><a href="tel:{{ item.phone }}">{{ item.phone }}</a></div>{% endif %}
{% if item.notes %}<div class="form-group full-width"><div class="form-label">Notes</div><div class="detail-body" style="white-space:pre-wrap">{{ item.notes }}</div></div>{% endif %}
{% if item.tags %}<div class="form-group full-width"><div class="form-label">Tags</div><div class="flex gap-2">{% for tag in item.tags %}<span class="row-tag">{{ tag }}</span>{% endfor %}</div></div>{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% extends "base.html" %}
{% block content %}
<div class="breadcrumb"><a href="/contacts">Contacts</a><span class="sep">/</span><span>{{ 'Edit' if item else 'New Contact' }}</span></div>
<div class="page-header"><h1 class="page-title">{{ 'Edit Contact' if item else 'New Contact' }}</h1></div>
<div class="card">
<form method="post" action="{{ '/contacts/' ~ item.id ~ '/edit' if item else '/contacts/create' }}">
<div class="form-grid">
<div class="form-group"><label class="form-label">First Name *</label><input type="text" name="first_name" class="form-input" required value="{{ item.first_name if item else '' }}"></div>
<div class="form-group"><label class="form-label">Last Name</label><input type="text" name="last_name" class="form-input" value="{{ item.last_name if item and item.last_name else '' }}"></div>
<div class="form-group"><label class="form-label">Company</label><input type="text" name="company" class="form-input" value="{{ item.company if item and item.company else '' }}"></div>
<div class="form-group"><label class="form-label">Role</label><input type="text" name="role" class="form-input" value="{{ item.role if item and item.role else '' }}"></div>
<div class="form-group"><label class="form-label">Email</label><input type="email" name="email" class="form-input" value="{{ item.email if item and item.email else '' }}"></div>
<div class="form-group"><label class="form-label">Phone</label><input type="tel" name="phone" class="form-input" value="{{ item.phone if item and item.phone else '' }}"></div>
<div class="form-group full-width"><label class="form-label">Notes</label><textarea name="notes" class="form-textarea" rows="4">{{ item.notes if item and item.notes else '' }}</textarea></div>
<div class="form-group full-width"><label class="form-label">Tags</label><input type="text" name="tags" class="form-input" value="{{ item.tags|join(', ') if item and item.tags else '' }}"></div>
<div class="form-actions"><button type="submit" class="btn btn-primary">{{ 'Save' if item else 'Create' }}</button><a href="/contacts" class="btn btn-secondary">Cancel</a></div>
</div>
</form></div>
{% endblock %}

25
templates/contacts.html Normal file
View File

@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Contacts<span class="page-count">{{ items|length }}</span></h1>
<a href="/contacts/create" class="btn btn-primary">+ New Contact</a>
</div>
{% if items %}
<div class="card">
{% for item in items %}
<div class="list-row">
<span class="row-title"><a href="/contacts/{{ item.id }}">{{ item.first_name }} {{ item.last_name or '' }}</a></span>
{% if item.company %}<span class="row-tag">{{ item.company }}</span>{% endif %}
{% if item.role %}<span class="row-meta">{{ item.role }}</span>{% endif %}
{% if item.email %}<span class="row-meta">{{ item.email }}</span>{% endif %}
<div class="row-actions">
<a href="/contacts/{{ item.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
<form action="/contacts/{{ item.id }}/delete" method="post" data-confirm="Delete?" style="display:inline"><button class="btn btn-ghost btn-xs" style="color:var(--red)">Del</button></form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state"><div class="empty-state-icon">&#128100;</div><div class="empty-state-text">No contacts yet</div><a href="/contacts/create" class="btn btn-primary">Add Contact</a></div>
{% endif %}
{% endblock %}

86
templates/dashboard.html Normal file
View File

@@ -0,0 +1,86 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Dashboard</h1>
</div>
<!-- Stats -->
<div class="dashboard-grid mb-4">
<div class="stat-card">
<div class="stat-value">{{ stats.open_tasks or 0 }}</div>
<div class="stat-label">Open Tasks</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.in_progress or 0 }}</div>
<div class="stat-label">In Progress</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.done_this_week or 0 }}</div>
<div class="stat-label">Done This Week</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ focus_items|length }}</div>
<div class="stat-label">Today's Focus</div>
</div>
</div>
<div class="dashboard-grid">
<!-- Today's Focus -->
<div class="card">
<div class="card-header">
<h2 class="card-title">Today's Focus</h2>
<a href="/focus" class="btn btn-ghost btn-sm">View All</a>
</div>
{% if focus_items %}
{% for item in focus_items %}
<div class="focus-item {{ 'completed' if item.completed }}">
<span class="priority-dot priority-{{ item.priority }}"></span>
<a href="/tasks/{{ item.task_id }}" class="focus-title">{{ item.title }}</a>
{% if item.project_name %}
<span class="row-meta">{{ item.project_name }}</span>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="empty-state">
<div class="empty-state-text">No focus items for today</div>
<a href="/focus" class="btn btn-primary btn-sm">Set Focus</a>
</div>
{% endif %}
</div>
<!-- Overdue + Upcoming -->
<div class="card">
<div class="card-header">
<h2 class="card-title">Upcoming</h2>
</div>
{% if overdue_tasks %}
<div class="text-xs text-muted mb-2" style="font-weight:600; color: var(--red);">OVERDUE</div>
{% for t in overdue_tasks %}
<div class="list-row">
<span class="priority-dot priority-{{ t.priority }}"></span>
<span class="row-title"><a href="/tasks/{{ t.id }}">{{ t.title }}</a></span>
<span class="row-meta overdue">{{ t.due_date }}</span>
</div>
{% endfor %}
{% endif %}
{% if upcoming_tasks %}
<div class="text-xs text-muted mb-2 {{ 'mt-3' if overdue_tasks }}" style="font-weight:600;">NEXT 7 DAYS</div>
{% for t in upcoming_tasks %}
<div class="list-row">
<span class="priority-dot priority-{{ t.priority }}"></span>
<span class="row-title"><a href="/tasks/{{ t.id }}">{{ t.title }}</a></span>
<span class="row-meta">{{ t.due_date }}</span>
</div>
{% endfor %}
{% endif %}
{% if not overdue_tasks and not upcoming_tasks %}
<div class="empty-state">
<div class="empty-state-text">No upcoming deadlines</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block content %}
<div class="breadcrumb"><a href="/domains">Domains</a><span class="sep">/</span><span>{{ 'Edit' if item else 'New Domain' }}</span></div>
<div class="page-header"><h1 class="page-title">{{ 'Edit Domain' if item else 'New Domain' }}</h1></div>
<div class="card">
<form method="post" action="{{ '/domains/' ~ item.id ~ '/edit' if item else '/domains/create' }}">
<div class="form-grid">
<div class="form-group"><label class="form-label">Name *</label><input type="text" name="name" class="form-input" required value="{{ item.name if item else '' }}"></div>
<div class="form-group"><label class="form-label">Color</label><input type="color" name="color" class="form-input" value="{{ item.color if item and item.color else '#4F6EF7' }}" style="height:42px;padding:4px"></div>
<div class="form-group full-width"><label class="form-label">Description</label><textarea name="description" class="form-textarea" rows="3">{{ item.description if item and item.description else '' }}</textarea></div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'Save' if item else 'Create' }}</button>
<a href="/domains" class="btn btn-secondary">Cancel</a>
</div>
</div>
</form></div>
{% endblock %}

26
templates/domains.html Normal file
View File

@@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Domains<span class="page-count">{{ items|length }}</span></h1>
<a href="/domains/create" class="btn btn-primary">+ New Domain</a>
</div>
{% if items %}
<div class="card">
{% for item in items %}
<div class="list-row">
<span class="domain-dot" style="background: {{ item.color or '#4F6EF7' }}"></span>
<span class="row-title"><a href="/tasks?domain_id={{ item.id }}">{{ item.name }}</a></span>
{% if item.description %}<span class="row-meta">{{ item.description }}</span>{% endif %}
<div class="row-actions">
<a href="/domains/{{ item.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
<form action="/domains/{{ item.id }}/delete" method="post" data-confirm="Delete this domain and all its contents?" style="display:inline">
<button class="btn btn-ghost btn-xs" style="color:var(--red)">Del</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state"><div class="empty-state-text">No domains. Create your first life domain.</div><a href="/domains/create" class="btn btn-primary">Create Domain</a></div>
{% endif %}
{% endblock %}

58
templates/focus.html Normal file
View File

@@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Daily Focus</h1>
<div class="flex items-center gap-2">
{% if total_estimated %}<span class="text-sm text-muted">~{{ total_estimated }}min estimated</span>{% endif %}
</div>
</div>
<div class="flex items-center gap-2 mb-4">
<a href="/focus?focus_date={{ (focus_date|string)[:10] }}" class="btn btn-ghost btn-sm">Today</a>
<span class="text-sm" style="font-weight:600">{{ focus_date }}</span>
</div>
<!-- Focus items -->
{% if items %}
{% for item in items %}
<div class="focus-item {{ 'completed' if item.completed }}">
<form action="/focus/{{ item.id }}/toggle" method="post" style="display:inline">
<div class="row-check">
<input type="checkbox" id="f-{{ item.id }}" {{ 'checked' if item.completed }} onchange="this.form.submit()">
<label for="f-{{ item.id }}"></label>
</div>
</form>
<span class="priority-dot priority-{{ item.priority }}"></span>
<a href="/tasks/{{ item.task_id }}" class="focus-title">{{ item.title }}</a>
{% if item.project_name %}<span class="row-tag">{{ item.project_name }}</span>{% endif %}
{% if item.estimated_minutes %}<span class="focus-meta">~{{ item.estimated_minutes }}min</span>{% endif %}
{% if item.due_date %}<span class="focus-meta">{{ item.due_date }}</span>{% endif %}
<form action="/focus/{{ item.id }}/remove" method="post" style="display:inline">
<button class="btn btn-ghost btn-xs" style="color:var(--red)" title="Remove from focus">&times;</button>
</form>
</div>
{% endfor %}
{% else %}
<div class="empty-state mb-4"><div class="empty-state-text">No focus items for this day</div></div>
{% endif %}
<!-- Add task to focus -->
{% if available_tasks %}
<div class="card mt-4">
<div class="card-header"><h2 class="card-title">Add to Focus</h2></div>
{% for t in available_tasks[:15] %}
<div class="list-row">
<span class="priority-dot priority-{{ t.priority }}"></span>
<span class="row-title">{{ t.title }}</span>
{% if t.project_name %}<span class="row-tag">{{ t.project_name }}</span>{% endif %}
{% if t.due_date %}<span class="row-meta">{{ t.due_date }}</span>{% endif %}
<form action="/focus/add" method="post" style="display:inline">
<input type="hidden" name="task_id" value="{{ t.id }}">
<input type="hidden" name="focus_date" value="{{ focus_date }}">
<button class="btn btn-ghost btn-xs" style="color:var(--green)">+ Focus</button>
</form>
</div>
{% endfor %}
</div>
{% endif %}
{% endblock %}

18
templates/link_form.html Normal file
View File

@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block content %}
<div class="breadcrumb"><a href="/links">Links</a><span class="sep">/</span><span>{{ 'Edit' if item else 'New Link' }}</span></div>
<div class="page-header"><h1 class="page-title">{{ 'Edit Link' if item else 'New Link' }}</h1></div>
<div class="card">
<form method="post" action="{{ '/links/' ~ item.id ~ '/edit' if item else '/links/create' }}">
<div class="form-grid">
<div class="form-group"><label class="form-label">Label *</label><input type="text" name="label" class="form-input" required value="{{ item.label if item else '' }}"></div>
<div class="form-group"><label class="form-label">URL *</label><input type="url" name="url" class="form-input" required value="{{ item.url if item else '' }}"></div>
<div class="form-group"><label class="form-label">Domain *</label>
<select name="domain_id" class="form-select" required>{% for d in domains %}<option value="{{ d.id }}" {{ 'selected' if (item and item.domain_id|string == d.id|string) or (not item and prefill_domain_id == d.id|string) }}>{{ d.name }}</option>{% endfor %}</select></div>
<div class="form-group"><label class="form-label">Project</label>
<select name="project_id" class="form-select"><option value="">-- None --</option>{% for p in projects %}<option value="{{ p.id }}" {{ 'selected' if (item and item.project_id and item.project_id|string == p.id|string) or (not item and prefill_project_id == p.id|string) }}>{{ p.name }}</option>{% endfor %}</select></div>
<div class="form-group full-width"><label class="form-label">Description</label><textarea name="description" class="form-textarea" rows="3">{{ item.description if item and item.description else '' }}</textarea></div>
<div class="form-actions"><button type="submit" class="btn btn-primary">{{ 'Save' if item else 'Create' }}</button><a href="/links" class="btn btn-secondary">Cancel</a></div>
</div>
</form></div>
{% endblock %}

25
templates/links.html Normal file
View File

@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Links<span class="page-count">{{ items|length }}</span></h1>
<a href="/links/create" class="btn btn-primary">+ New Link</a>
</div>
{% if items %}
<div class="card">
{% for item in items %}
<div class="list-row">
{% if item.domain_color %}<span class="row-domain-tag" style="background:{{ item.domain_color }}22;color:{{ item.domain_color }}">{{ item.domain_name }}</span>{% endif %}
<span class="row-title"><a href="{{ item.url }}" target="_blank">{{ item.label }}</a></span>
{% if item.project_name %}<span class="row-tag">{{ item.project_name }}</span>{% endif %}
<span class="row-meta">{{ item.url[:50] }}{% if item.url|length > 50 %}...{% endif %}</span>
<div class="row-actions">
<a href="/links/{{ item.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
<form action="/links/{{ item.id }}/delete" method="post" data-confirm="Delete?" style="display:inline"><button class="btn btn-ghost btn-xs" style="color:var(--red)">Del</button></form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state"><div class="empty-state-icon">&#128279;</div><div class="empty-state-text">No links yet</div><a href="/links/create" class="btn btn-primary">Add Link</a></div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block content %}
<div class="breadcrumb">
{% if domain %}<span style="color:{{ domain.color }}">{{ domain.name }}</span><span class="sep">/</span>{% endif %}
{% if project %}<a href="/projects/{{ project.id }}">{{ project.name }}</a><span class="sep">/</span>{% endif %}
<span>{{ item.title }}</span>
</div>
<div class="detail-header">
<div class="flex items-center justify-between">
<h1 class="detail-title">{{ item.title }}</h1>
<div class="flex gap-2">
<a href="/notes/{{ item.id }}/edit" class="btn btn-secondary btn-sm">Edit</a>
<form action="/notes/{{ item.id }}/delete" method="post" data-confirm="Delete?" style="display:inline"><button class="btn btn-danger btn-sm">Delete</button></form>
</div>
</div>
<div class="detail-meta mt-2">
<span class="detail-meta-item">Updated {{ item.updated_at.strftime('%Y-%m-%d %H:%M') if item.updated_at else '' }}</span>
{% if item.tags %}{% for tag in item.tags %}<span class="row-tag">{{ tag }}</span>{% endfor %}{% endif %}
</div>
</div>
{% if item.body %}
<div class="card"><div class="detail-body" style="white-space:pre-wrap;font-family:var(--font-body)">{{ item.body }}</div></div>
{% else %}
<div class="card"><div class="text-muted" style="padding:20px">No content yet. <a href="/notes/{{ item.id }}/edit">Start writing</a></div></div>
{% endif %}
{% endblock %}

19
templates/note_form.html Normal file
View File

@@ -0,0 +1,19 @@
{% extends "base.html" %}
{% block content %}
<div class="breadcrumb"><a href="/notes">Notes</a><span class="sep">/</span><span>{{ 'Edit' if item else 'New Note' }}</span></div>
<div class="page-header"><h1 class="page-title">{{ 'Edit Note' if item else 'New Note' }}</h1></div>
<div class="card">
<form method="post" action="{{ '/notes/' ~ item.id ~ '/edit' if item else '/notes/create' }}">
<div class="form-grid">
<div class="form-group full-width"><label class="form-label">Title *</label><input type="text" name="title" class="form-input" required value="{{ item.title if item else '' }}"></div>
<div class="form-group"><label class="form-label">Domain *</label>
<select name="domain_id" class="form-select" required>{% for d in domains %}<option value="{{ d.id }}" {{ 'selected' if (item and item.domain_id|string == d.id|string) or (not item and prefill_domain_id == d.id|string) }}>{{ d.name }}</option>{% endfor %}</select></div>
<div class="form-group"><label class="form-label">Project</label>
<select name="project_id" class="form-select"><option value="">-- None --</option>{% for p in projects %}<option value="{{ p.id }}" {{ 'selected' if (item and item.project_id and item.project_id|string == p.id|string) or (not item and prefill_project_id == p.id|string) }}>{{ p.name }}</option>{% endfor %}</select></div>
<div class="form-group full-width"><label class="form-label">Content</label><textarea name="body" class="form-textarea" rows="15" style="font-family:var(--font-mono);font-size:0.88rem">{{ item.body if item and item.body else '' }}</textarea></div>
<div class="form-group full-width"><label class="form-label">Tags</label><input type="text" name="tags" class="form-input" value="{{ item.tags|join(', ') if item and item.tags else '' }}"></div>
<input type="hidden" name="content_format" value="rich">
<div class="form-actions"><button type="submit" class="btn btn-primary">{{ 'Save' if item else 'Create' }}</button><a href="{{ '/notes/' ~ item.id if item else '/notes' }}" class="btn btn-secondary">Cancel</a></div>
</div>
</form></div>
{% endblock %}

25
templates/notes.html Normal file
View File

@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Notes<span class="page-count">{{ items|length }}</span></h1>
<a href="/notes/create" class="btn btn-primary">+ New Note</a>
</div>
{% if items %}
<div class="card">
{% for item in items %}
<div class="list-row">
{% if item.domain_color %}<span class="row-domain-tag" style="background:{{ item.domain_color }}22;color:{{ item.domain_color }}">{{ item.domain_name }}</span>{% endif %}
<span class="row-title"><a href="/notes/{{ item.id }}">{{ item.title }}</a></span>
{% if item.project_name %}<span class="row-tag">{{ item.project_name }}</span>{% endif %}
<span class="row-meta">{{ item.updated_at.strftime('%Y-%m-%d') if item.updated_at else '' }}</span>
<div class="row-actions">
<a href="/notes/{{ item.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
<form action="/notes/{{ item.id }}/delete" method="post" data-confirm="Delete?" style="display:inline"><button class="btn btn-ghost btn-xs" style="color:var(--red)">Del</button></form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state"><div class="empty-state-icon">&#128196;</div><div class="empty-state-text">No notes yet</div><a href="/notes/create" class="btn btn-primary">Create Note</a></div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,95 @@
{% extends "base.html" %}
{% block content %}
<div class="breadcrumb">
{% if domain %}<span style="color: {{ domain.color or '#4F6EF7' }}">{{ domain.name }}</span><span class="sep">/</span>{% endif %}
{% if area %}<span>{{ area.name }}</span><span class="sep">/</span>{% endif %}
<span>{{ item.name }}</span>
</div>
<div class="detail-header">
<div class="flex items-center justify-between">
<h1 class="detail-title">{{ item.name }}</h1>
<div class="flex gap-2">
<a href="/projects/{{ item.id }}/edit" class="btn btn-secondary btn-sm">Edit</a>
<form action="/projects/{{ item.id }}/delete" method="post" data-confirm="Delete this project?" style="display:inline">
<button class="btn btn-danger btn-sm">Delete</button>
</form>
</div>
</div>
<div class="detail-meta mt-2">
<span class="status-badge status-{{ item.status }}">{{ item.status|replace('_', ' ') }}</span>
<span class="detail-meta-item"><span class="priority-dot priority-{{ item.priority }}"></span> P{{ item.priority }}</span>
{% if item.target_date %}<span class="detail-meta-item">Target: {{ item.target_date }}</span>{% endif %}
</div>
<div class="mt-2" style="max-width: 300px;">
<div class="progress-bar"><div class="progress-fill" style="width: {{ progress }}%"></div></div>
<div class="progress-text">{{ done_count }}/{{ task_count }} tasks complete ({{ progress }}%)</div>
</div>
</div>
{% if item.description %}
<div class="card mb-4"><div class="detail-body">{{ item.description }}</div></div>
{% endif %}
<!-- Tabs -->
<div class="tab-strip">
<a href="/projects/{{ item.id }}?tab=tasks" class="tab-item {{ 'active' if tab == 'tasks' }}">Tasks ({{ tasks|length }})</a>
<a href="/projects/{{ item.id }}?tab=notes" class="tab-item {{ 'active' if tab == 'notes' }}">Notes ({{ notes|length }})</a>
<a href="/projects/{{ item.id }}?tab=links" class="tab-item {{ 'active' if tab == 'links' }}">Links ({{ links|length }})</a>
</div>
{% if tab == 'tasks' %}
<form class="quick-add" action="/tasks/quick-add" method="post">
<input type="hidden" name="domain_id" value="{{ item.domain_id }}">
<input type="hidden" name="project_id" value="{{ item.id }}">
<input type="text" name="title" placeholder="Quick add task to this project..." required>
<button type="submit" class="btn btn-primary btn-sm">Add</button>
</form>
<a href="/tasks/create?domain_id={{ item.domain_id }}&project_id={{ item.id }}" class="btn btn-ghost btn-sm mb-3">+ Full Task Form</a>
{% for t in tasks %}
<div class="list-row {{ 'completed' if t.status == 'done' }}">
<div class="row-check">
<form action="/tasks/{{ t.id }}/toggle" method="post" style="display:inline">
<input type="checkbox" id="pt-{{ t.id }}" {{ 'checked' if t.status == 'done' }} onchange="this.form.submit()">
<label for="pt-{{ t.id }}"></label>
</form>
</div>
<span class="priority-dot priority-{{ t.priority }}"></span>
<span class="row-title"><a href="/tasks/{{ t.id }}">{{ t.title }}</a></span>
{% if t.due_date %}<span class="row-meta">{{ t.due_date }}</span>{% endif %}
<span class="status-badge status-{{ t.status }}">{{ t.status|replace('_', ' ') }}</span>
<div class="row-actions">
<a href="/tasks/{{ t.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
</div>
</div>
{% else %}
<div class="empty-state"><div class="empty-state-text">No tasks yet</div></div>
{% endfor %}
{% elif tab == 'notes' %}
<a href="/notes/create?domain_id={{ item.domain_id }}&project_id={{ item.id }}" class="btn btn-ghost btn-sm mb-3">+ New Note</a>
{% for n in notes %}
<div class="list-row">
<span class="row-title"><a href="/notes/{{ n.id }}">{{ n.title }}</a></span>
<span class="row-meta">{{ n.updated_at.strftime('%Y-%m-%d') if n.updated_at else '' }}</span>
</div>
{% else %}
<div class="empty-state"><div class="empty-state-text">No notes yet</div></div>
{% endfor %}
{% elif tab == 'links' %}
<a href="/links/create?domain_id={{ item.domain_id }}&project_id={{ item.id }}" class="btn btn-ghost btn-sm mb-3">+ New Link</a>
{% for l in links %}
<div class="list-row">
<span class="row-title"><a href="{{ l.url }}" target="_blank">{{ l.label }}</a></span>
<span class="row-meta">{{ l.url[:50] }}{% if l.url|length > 50 %}...{% endif %}</span>
<div class="row-actions">
<a href="/links/{{ l.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
</div>
</div>
{% else %}
<div class="empty-state"><div class="empty-state-text">No links yet</div></div>
{% endfor %}
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,69 @@
{% extends "base.html" %}
{% block content %}
<div class="breadcrumb"><a href="/projects">Projects</a><span class="sep">/</span><span>{{ 'Edit' if item else 'New Project' }}</span></div>
<div class="page-header"><h1 class="page-title">{{ 'Edit Project' if item else 'New Project' }}</h1></div>
<div class="card">
<form method="post" action="{{ '/projects/' ~ item.id ~ '/edit' if item else '/projects/create' }}">
<div class="form-grid">
<div class="form-group full-width">
<label class="form-label">Name *</label>
<input type="text" name="name" class="form-input" required value="{{ item.name if item else '' }}">
</div>
<div class="form-group">
<label class="form-label">Domain *</label>
<select name="domain_id" class="form-select" required>
{% for d in domains %}
<option value="{{ d.id }}" {{ 'selected' if (item and item.domain_id|string == d.id|string) or (not item and prefill_domain_id == d.id|string) }}>{{ d.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label class="form-label">Area</label>
<select name="area_id" class="form-select">
<option value="">-- None --</option>
{% for a in areas %}
<option value="{{ a.id }}" {{ 'selected' if (item and item.area_id and item.area_id|string == a.id|string) or (not item and prefill_area_id == a.id|string) }}>{{ a.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label class="form-label">Status</label>
<select name="status" class="form-select">
{% for s in ['active', 'on_hold', 'completed', 'archived'] %}
<option value="{{ s }}" {{ 'selected' if item and item.status == s }}>{{ s|replace('_', ' ')|title }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label class="form-label">Priority</label>
<select name="priority" class="form-select">
<option value="1" {{ 'selected' if item and item.priority == 1 }}>Critical</option>
<option value="2" {{ 'selected' if item and item.priority == 2 }}>High</option>
<option value="3" {{ 'selected' if (item and item.priority == 3) or not item }}>Normal</option>
<option value="4" {{ 'selected' if item and item.priority == 4 }}>Low</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Start Date</label>
<input type="date" name="start_date" class="form-input" value="{{ item.start_date if item and item.start_date else '' }}">
</div>
<div class="form-group">
<label class="form-label">Target Date</label>
<input type="date" name="target_date" class="form-input" value="{{ item.target_date if item and item.target_date else '' }}">
</div>
<div class="form-group full-width">
<label class="form-label">Description</label>
<textarea name="description" class="form-textarea">{{ item.description if item and item.description else '' }}</textarea>
</div>
<div class="form-group full-width">
<label class="form-label">Tags (comma-separated)</label>
<input type="text" name="tags" class="form-input" value="{{ item.tags|join(', ') if item and item.tags else '' }}">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'Save Changes' if item else 'Create Project' }}</button>
<a href="{{ '/projects/' ~ item.id if item else '/projects' }}" class="btn btn-secondary">Cancel</a>
</div>
</div>
</form>
</div>
{% endblock %}

48
templates/projects.html Normal file
View File

@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Projects<span class="page-count">{{ items|length }}</span></h1>
<a href="/projects/create" class="btn btn-primary">+ New Project</a>
</div>
<form class="filters-bar" method="get" action="/projects">
<select name="domain_id" class="filter-select" onchange="this.form.submit()">
<option value="">All Domains</option>
{% for d in domains %}
<option value="{{ d.id }}" {{ 'selected' if current_domain_id == d.id|string }}>{{ d.name }}</option>
{% endfor %}
</select>
<select name="status" class="filter-select" onchange="this.form.submit()">
<option value="">All Statuses</option>
{% for s in ['active', 'on_hold', 'completed', 'archived'] %}
<option value="{{ s }}" {{ 'selected' if current_status == s }}>{{ s|replace('_', ' ')|title }}</option>
{% endfor %}
</select>
</form>
{% if items %}
<div class="card">
{% for item in items %}
<div class="list-row">
<span class="row-domain-tag" style="background: {{ item.domain_color or '#4F6EF7' }}22; color: {{ item.domain_color or '#4F6EF7' }}">{{ item.domain_name }}</span>
<span class="row-title"><a href="/projects/{{ item.id }}">{{ item.name }}</a></span>
{% if item.area_name %}<span class="row-meta">{{ item.area_name }}</span>{% endif %}
<span class="status-badge status-{{ item.status }}">{{ item.status|replace('_', ' ') }}</span>
<div style="width: 80px;">
<div class="progress-bar"><div class="progress-fill" style="width: {{ item.progress }}%"></div></div>
<div class="progress-text">{{ item.done_count }}/{{ item.task_count }}</div>
</div>
<div class="row-actions">
<a href="/projects/{{ item.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-state-icon">&#128194;</div>
<div class="empty-state-text">No projects found</div>
<a href="/projects/create" class="btn btn-primary">Create First Project</a>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block content %}
<div class="breadcrumb">
{% if domain %}<a href="/tasks?domain_id={{ item.domain_id }}">{{ domain.name }}</a><span class="sep">/</span>{% endif %}
{% if project %}<a href="/projects/{{ project.id }}">{{ project.name }}</a><span class="sep">/</span>{% endif %}
<span>{{ item.title }}</span>
</div>
<div class="detail-header">
<div class="flex items-center justify-between">
<h1 class="detail-title">{{ item.title }}</h1>
<div class="flex gap-2">
<a href="/tasks/{{ item.id }}/edit" class="btn btn-secondary btn-sm">Edit</a>
<form action="/tasks/{{ item.id }}/toggle" method="post" style="display:inline">
<button class="btn {{ 'btn-secondary' if item.status == 'done' else 'btn-primary' }} btn-sm">
{{ 'Reopen' if item.status == 'done' else 'Complete' }}
</button>
</form>
</div>
</div>
<div class="detail-meta mt-2">
<span class="status-badge status-{{ item.status }}">{{ item.status|replace('_', ' ') }}</span>
<span class="detail-meta-item"><span class="priority-dot priority-{{ item.priority }}"></span> P{{ item.priority }}</span>
{% if domain %}<span class="row-domain-tag" style="background: {{ domain.color or '#4F6EF7' }}22; color: {{ domain.color or '#4F6EF7' }}">{{ domain.name }}</span>{% endif %}
{% if project %}<span class="row-tag">{{ project.name }}</span>{% endif %}
{% if item.due_date %}<span class="detail-meta-item">Due: {{ item.due_date }}</span>{% endif %}
{% if item.context %}<span class="detail-meta-item">@{{ item.context }}</span>{% endif %}
{% if item.estimated_minutes %}<span class="detail-meta-item">~{{ item.estimated_minutes }}min</span>{% endif %}
{% if item.energy_required %}<span class="detail-meta-item">Energy: {{ item.energy_required }}</span>{% endif %}
</div>
</div>
{% if item.description %}
<div class="card mb-4">
<div class="detail-body">{{ item.description }}</div>
</div>
{% endif %}
{% if item.tags %}
<div class="flex gap-2 mb-4">
{% for tag in item.tags %}<span class="row-tag">{{ tag }}</span>{% endfor %}
</div>
{% endif %}
{% if parent %}
<div class="card mb-4">
<div class="card-title text-sm">Parent Task</div>
<a href="/tasks/{{ parent.id }}">{{ parent.title }}</a>
</div>
{% endif %}
<!-- Subtasks -->
<div class="card">
<div class="card-header">
<h2 class="card-title">Subtasks<span class="page-count">{{ subtasks|length }}</span></h2>
<a href="/tasks/create?parent_id={{ item.id }}&domain_id={{ item.domain_id }}&project_id={{ item.project_id or '' }}" class="btn btn-ghost btn-sm">+ Add Subtask</a>
</div>
{% for sub in subtasks %}
<div class="list-row {{ 'completed' if sub.status == 'done' }}">
<div class="row-check">
<form action="/tasks/{{ sub.id }}/toggle" method="post" style="display:inline">
<input type="checkbox" id="sub-{{ sub.id }}" {{ 'checked' if sub.status == 'done' }} onchange="this.form.submit()">
<label for="sub-{{ sub.id }}"></label>
</form>
</div>
<span class="priority-dot priority-{{ sub.priority }}"></span>
<span class="row-title"><a href="/tasks/{{ sub.id }}">{{ sub.title }}</a></span>
<span class="status-badge status-{{ sub.status }}">{{ sub.status|replace('_', ' ') }}</span>
</div>
{% else %}
<div class="text-sm text-muted" style="padding: 12px;">No subtasks</div>
{% endfor %}
</div>
<div class="text-xs text-muted mt-4">
Created {{ item.created_at.strftime('%Y-%m-%d %H:%M') if item.created_at else '' }}
{% if item.completed_at %} | Completed {{ item.completed_at.strftime('%Y-%m-%d %H:%M') }}{% endif %}
</div>
{% endblock %}

119
templates/task_form.html Normal file
View File

@@ -0,0 +1,119 @@
{% extends "base.html" %}
{% block content %}
<div class="breadcrumb">
<a href="/tasks">Tasks</a>
<span class="sep">/</span>
<span>{{ 'Edit' if item else 'New Task' }}</span>
</div>
<div class="page-header">
<h1 class="page-title">{{ 'Edit Task' if item else 'New Task' }}</h1>
</div>
<div class="card">
<form method="post" action="{{ '/tasks/' ~ item.id ~ '/edit' if item else '/tasks/create' }}">
<div class="form-grid">
<div class="form-group full-width">
<label class="form-label">Title *</label>
<input type="text" name="title" class="form-input" required
value="{{ item.title if item else '' }}">
</div>
<div class="form-group">
<label class="form-label">Domain *</label>
<select name="domain_id" class="form-select" required>
{% for d in domains %}
<option value="{{ d.id }}"
{{ 'selected' if (item and item.domain_id|string == d.id|string) or (not item and prefill_domain_id == d.id|string) }}>
{{ d.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label class="form-label">Project</label>
<select name="project_id" class="form-select">
<option value="">-- No Project --</option>
{% for p in projects %}
<option value="{{ p.id }}"
{{ 'selected' if (item and item.project_id and item.project_id|string == p.id|string) or (not item and prefill_project_id == p.id|string) }}>
{{ p.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label class="form-label">Priority</label>
<select name="priority" class="form-select">
<option value="1" {{ 'selected' if item and item.priority == 1 }}>1 - Critical</option>
<option value="2" {{ 'selected' if item and item.priority == 2 }}>2 - High</option>
<option value="3" {{ 'selected' if (item and item.priority == 3) or not item }}>3 - Normal</option>
<option value="4" {{ 'selected' if item and item.priority == 4 }}>4 - Low</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Status</label>
<select name="status" class="form-select">
{% for s in ['open', 'in_progress', 'blocked', 'done', 'cancelled'] %}
<option value="{{ s }}" {{ 'selected' if item and item.status == s }}>{{ s|replace('_', ' ')|title }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label class="form-label">Due Date</label>
<input type="date" name="due_date" class="form-input"
value="{{ item.due_date if item and item.due_date else '' }}">
</div>
<div class="form-group">
<label class="form-label">Context</label>
<select name="context" class="form-select">
<option value="">-- None --</option>
{% for ct in context_types %}
<option value="{{ ct.value }}"
{{ 'selected' if item and item.context == ct.value }}>{{ ct.label }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label class="form-label">Estimated Minutes</label>
<input type="number" name="estimated_minutes" class="form-input" min="0"
value="{{ item.estimated_minutes if item and item.estimated_minutes else '' }}">
</div>
<div class="form-group">
<label class="form-label">Energy Required</label>
<select name="energy_required" class="form-select">
<option value="">-- None --</option>
<option value="high" {{ 'selected' if item and item.energy_required == 'high' }}>High</option>
<option value="medium" {{ 'selected' if item and item.energy_required == 'medium' }}>Medium</option>
<option value="low" {{ 'selected' if item and item.energy_required == 'low' }}>Low</option>
</select>
</div>
<div class="form-group full-width">
<label class="form-label">Description</label>
<textarea name="description" class="form-textarea">{{ item.description if item and item.description else '' }}</textarea>
</div>
<div class="form-group full-width">
<label class="form-label">Tags (comma-separated)</label>
<input type="text" name="tags" class="form-input"
value="{{ item.tags|join(', ') if item and item.tags else '' }}">
</div>
{% if prefill_parent_id %}
<input type="hidden" name="parent_id" value="{{ prefill_parent_id }}">
{% endif %}
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'Save Changes' if item else 'Create Task' }}</button>
<a href="{{ '/tasks/' ~ item.id if item else '/tasks' }}" class="btn btn-secondary">Cancel</a>
</div>
</div>
</form>
</div>
{% endblock %}

91
templates/tasks.html Normal file
View File

@@ -0,0 +1,91 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">All Tasks<span class="page-count">{{ items|length }}</span></h1>
<a href="/tasks/create" class="btn btn-primary">+ New Task</a>
</div>
<!-- Quick Add -->
<form class="quick-add" action="/tasks/quick-add" method="post">
<input type="text" name="title" placeholder="Quick add task..." required>
<button type="submit" class="btn btn-primary btn-sm">Add</button>
</form>
<!-- Filters -->
<form class="filters-bar" method="get" action="/tasks">
<select name="status" class="filter-select" data-auto-submit onchange="this.form.submit()">
<option value="">All Statuses</option>
<option value="open" {{ 'selected' if current_status == 'open' }}>Open</option>
<option value="in_progress" {{ 'selected' if current_status == 'in_progress' }}>In Progress</option>
<option value="blocked" {{ 'selected' if current_status == 'blocked' }}>Blocked</option>
<option value="done" {{ 'selected' if current_status == 'done' }}>Done</option>
</select>
<select name="priority" class="filter-select" onchange="this.form.submit()">
<option value="">All Priorities</option>
<option value="1" {{ 'selected' if current_priority == '1' }}>Critical</option>
<option value="2" {{ 'selected' if current_priority == '2' }}>High</option>
<option value="3" {{ 'selected' if current_priority == '3' }}>Normal</option>
<option value="4" {{ 'selected' if current_priority == '4' }}>Low</option>
</select>
<select name="domain_id" class="filter-select" onchange="this.form.submit()">
<option value="">All Domains</option>
{% for d in domains %}
<option value="{{ d.id }}" {{ 'selected' if current_domain_id == d.id|string }}>{{ d.name }}</option>
{% endfor %}
</select>
<select name="project_id" class="filter-select" onchange="this.form.submit()">
<option value="">All Projects</option>
{% for p in projects %}
<option value="{{ p.id }}" {{ 'selected' if current_project_id == p.id|string }}>{{ p.name }}</option>
{% endfor %}
</select>
<select name="sort" class="filter-select" onchange="this.form.submit()">
<option value="sort_order" {{ 'selected' if current_sort == 'sort_order' }}>Manual Order</option>
<option value="priority" {{ 'selected' if current_sort == 'priority' }}>Priority</option>
<option value="due_date" {{ 'selected' if current_sort == 'due_date' }}>Due Date</option>
<option value="created_at" {{ 'selected' if current_sort == 'created_at' }}>Newest</option>
<option value="title" {{ 'selected' if current_sort == 'title' }}>Title</option>
</select>
</form>
<!-- Task List -->
{% if items %}
<div class="card">
{% for item in items %}
<div class="list-row {{ 'completed' if item.status in ['done', 'cancelled'] }}">
<div class="row-check">
<form action="/tasks/{{ item.id }}/toggle" method="post" style="display:inline">
<input type="checkbox" id="check-{{ item.id }}" {{ 'checked' if item.status == 'done' }}
onchange="this.form.submit()">
<label for="check-{{ item.id }}"></label>
</form>
</div>
<span class="priority-dot priority-{{ item.priority }}"></span>
<span class="row-title"><a href="/tasks/{{ item.id }}">{{ item.title }}</a></span>
{% if item.project_name %}
<span class="row-tag">{{ item.project_name }}</span>
{% endif %}
{% if item.domain_name %}
<span class="row-domain-tag" style="background: {{ item.domain_color or '#4F6EF7' }}22; color: {{ item.domain_color or '#4F6EF7' }}">{{ item.domain_name }}</span>
{% endif %}
{% if item.due_date %}
<span class="row-meta {{ 'overdue' if item.due_date|string < now_date|default('9999') }}">{{ item.due_date }}</span>
{% endif %}
<span class="status-badge status-{{ item.status }}">{{ item.status|replace('_', ' ') }}</span>
<div class="row-actions">
<a href="/tasks/{{ item.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
<form action="/tasks/{{ item.id }}/delete" method="post" data-confirm="Delete this task?" style="display:inline">
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Del</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-state-icon">&#9745;</div>
<div class="empty-state-text">No tasks found</div>
<a href="/tasks/create" class="btn btn-primary">Create First Task</a>
</div>
{% endif %}
{% endblock %}