Initial commit

This commit is contained in:
2026-03-03 00:44:33 +00:00
commit 5297da485f
126 changed files with 54767 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/

443
CLAUDE.md Normal file
View File

@@ -0,0 +1,443 @@
# CLAUDE.md
## Project Identity
Life OS is a single-user personal productivity web app. Server-rendered monolith. No SPA, no frontend framework, no build pipeline. You are working on the codebase directly on the production server via mounted Docker volume.
**Live URL:** https://lifeos-dev.invixiom.com
**Server:** defiant-01, 46.225.166.142, Ubuntu 24.04
**Repo:** github.com/mdombaugh/lifeos-dev (main branch)
---
## Tech Stack - Do Not Deviate
- **Python 3.12 / FastAPI** (async) / **SQLAlchemy 2.0 async** with raw SQL via `text()` - **NO ORM models**
- **PostgreSQL 16** in Docker container `lifeos-db`, full-text search via tsvector/tsquery
- **Jinja2** server-rendered HTML templates
- **Vanilla CSS/JS** only. No npm. No React. No Tailwind. No build tools.
- **asyncpg** driver
- CSS custom properties for dark/light theme (`[data-theme='dark']` / `[data-theme='light']`)
**Hard rules:**
- No fetch/XHR from JavaScript. Forms use standard HTML POST with 303 redirect (PRG pattern).
- JavaScript handles ONLY UI state: sidebar collapse, search modal, timer display, theme toggle, drag-to-reorder.
- All data operations go through BaseRepository or raw SQL. Never introduce an ORM model layer.
---
## Working Directory
All application code lives at `/opt/lifeos/dev/` on the server, mounted into the Docker container as `/app`. The container runs `uvicorn main:app --host 0.0.0.0 --port 8003 --workers 1 --reload`, so any file change is picked up immediately. No container rebuild needed.
```
/opt/lifeos/dev/
main.py # FastAPI app init, 18 router includes
.env # DB creds (never commit)
Dockerfile
requirements.txt
core/
__init__.py
database.py # Async engine, session factory, get_db dependency
base_repository.py # Generic CRUD with soft deletes
sidebar.py # Domain > area > project nav tree + badge counts
routers/
domains.py, areas.py, projects.py, tasks.py
notes.py, links.py, focus.py, capture.py, contacts.py
search.py, admin.py, lists.py
files.py, meetings.py, decisions.py, weblinks.py
appointments.py, time_tracking.py
templates/
base.html # Shell: topbar, sidebar, JS includes
dashboard.html, search.html, trash.html
[entity].html, [entity]_form.html, [entity]_detail.html
(42 templates total - all extend base.html)
static/
style.css # ~1040 lines
app.js # ~190 lines
tests/
introspect.py # Route discovery engine
form_factory.py # Form data generation
registry.py # Route registry + seed mapping
conftest.py # Fixtures: DB, client, 15 seed entities
test_smoke_dynamic.py # Auto-parametrized GET tests
test_crud_dynamic.py # Auto-parametrized POST tests
test_business_logic.py # Hand-written behavioral tests
route_report.py # CLI route dump
run_tests.sh # Test runner
```
---
## How to Make Changes
**Read before writing.** Before modifying any file, read it first. The codebase has consistent patterns. Match them exactly.
**Do not create deploy scripts or heredoc wrappers.** You have direct filesystem access to `/opt/lifeos/dev/`. Edit files in place. Hot reload handles the rest.
**After changes, verify:**
```bash
docker logs lifeos-dev --tail 10 # Check for import/syntax errors
curl -s -o /dev/null -w "%{http_code}" http://localhost:8003/ # Should be 200
```
**Commit when asked:**
```bash
cd /opt/lifeos/dev && git add . && git commit -m "description" && git push origin main
```
Push uses PAT (personal access token) as password.
---
## Database Access
```bash
# Query
docker exec lifeos-db psql -U postgres -d lifeos_dev -c "SQL HERE"
# Inspect table schema (DO THIS before writing code that touches a table)
docker exec lifeos-db psql -U postgres -d lifeos_dev -c "\d table_name"
# List all tables
docker exec lifeos-db psql -U postgres -d lifeos_dev -c "\dt"
```
**Connection string (in .env):**
```
DATABASE_URL=postgresql+asyncpg://postgres:UCTOQDZiUhN8U@lifeos-db:5432/lifeos_dev
FILE_STORAGE_PATH=/opt/lifeos/files/dev
ENVIRONMENT=development
```
**Three databases exist in lifeos-db:**
- `lifeos_dev` - Active development (this is what you work with)
- `lifeos_prod` - Production data (DO NOT TOUCH)
- `lifeos_test` - Test suite database
**Always verify table schemas against the live DB before writing code.** The .sql files in the project-docs folder may be stale. The database is the source of truth:
```bash
docker exec lifeos-db psql -U postgres -d lifeos_dev -c "\d tasks"
```
---
## Backup Before Risky Changes
```bash
mkdir -p /opt/lifeos/backups
docker exec lifeos-db pg_dump -U postgres -d lifeos_dev -Fc -f /tmp/lifeos_dev_backup.dump
docker cp lifeos-db:/tmp/lifeos_dev_backup.dump /opt/lifeos/backups/lifeos_dev_$(date +%Y%m%d_%H%M%S).dump
```
**Restore:**
```bash
docker exec lifeos-db psql -U postgres -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'lifeos_dev' AND pid <> pg_backend_pid();"
docker exec lifeos-db psql -U postgres -c "DROP DATABASE lifeos_dev;"
docker exec lifeos-db psql -U postgres -c "CREATE DATABASE lifeos_dev;"
docker cp /opt/lifeos/backups/FILENAME.dump lifeos-db:/tmp/restore.dump
docker exec lifeos-db pg_restore -U postgres -d lifeos_dev /tmp/restore.dump
docker restart lifeos-dev
```
---
## BaseRepository Pattern - Use It
All CRUD goes through `core/base_repository.py`. Read this file to understand the API. Key methods:
- `list(filters={}, sort='sort_order')` - Auto-filters `is_deleted=false`
- `get(id)` - Single record by UUID
- `create(data)` - Auto-sets created_at, updated_at, is_deleted=false
- `update(id, data)` - Auto-sets updated_at
- `soft_delete(id)` - Sets is_deleted=true, deleted_at=now()
- `restore(id)` - Reverses soft_delete
- `permanent_delete(id)` - Actual SQL DELETE (admin only)
- `bulk_soft_delete(ids)`, `reorder(ids)`, `count(filters)`, `list_deleted()`
**Usage:**
```python
repo = BaseRepository("tasks", db)
items = await repo.list(filters={"project_id": project_id})
```
**Nullable fields gotcha:** When a form field should allow setting a value to empty/null, the field name MUST be in the `nullable_fields` set in `core/base_repository.py`. Otherwise `update()` silently skips null values. Check this set before adding new nullable form fields.
---
## Router Pattern - Follow Exactly
Every router follows this structure. Do not invent new patterns.
```python
from fastapi import APIRouter, Request, Form, Depends
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
from core.base_repository import BaseRepository
from core.database import get_db
from core.sidebar import get_sidebar_data
router = APIRouter(prefix='/things', tags=['things'])
templates = Jinja2Templates(directory='templates')
@router.get('/')
async def list_things(request: Request, db=Depends(get_db)):
repo = BaseRepository("things", db)
items = await repo.list()
sidebar = await get_sidebar_data(db)
return templates.TemplateResponse('things.html', {
'request': request, 'items': items, 'sidebar': sidebar
})
@router.post('/create')
async def create_thing(request: Request, db=Depends(get_db),
title: str = Form(...), description: str = Form(None)):
repo = BaseRepository("things", db)
item = await repo.create({'title': title, 'description': description})
return RedirectResponse(url=f'/things/{item["id"]}', status_code=303)
```
**Every route MUST call `get_sidebar_data(db)` and pass `sidebar` to the template.** The sidebar navigation breaks without this.
---
## Adding a New Entity - Checklist
1. Create `routers/entity_name.py` using the pattern above
2. Add import + `app.include_router(router)` in `main.py`
3. Create templates in `templates/`: list, form, detail (all extend `base.html`)
4. Add nav link in `templates/base.html` sidebar section
5. Add entity config to `SEARCH_ENTITIES` dict in `routers/search.py`
6. Add entity config to `TRASH_ENTITIES` dict in `routers/admin.py`
7. Add any new nullable fields to `nullable_fields` in `core/base_repository.py`
8. If entity has a new table, add seed fixture to `tests/conftest.py` and prefix mapping to `tests/registry.py`
9. Test: visit the list page, create an item, edit it, delete it, verify it appears in trash and search
---
## Universal Column Conventions
Every table follows this structure. When creating new tables, match it exactly:
```sql
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- [foreign keys, nullable where optional]
-- [content fields]
tags TEXT[],
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
-- searchable tables also get:
-- search_vector TSVECTOR (maintained by trigger)
-- CREATE INDEX idx_tablename_search ON tablename USING GIN(search_vector);
```
---
## Key Enums / Values
**Task status:** `open | in_progress | blocked | done | cancelled`
**Task priority:** `1=critical, 2=high, 3=normal, 4=low`
**Project status:** `active | on_hold | completed | archived`
**Process type:** `workflow | checklist`
---
## Known Traps
1. **`time_entries` has no `updated_at` column.** BaseRepository.soft_delete() will fail on this table. Use direct SQL instead. Same for restore.
2. **Junction tables use raw SQL, not BaseRepository.**
```python
await db.execute(text("INSERT INTO contact_meetings (contact_id, meeting_id, role) VALUES (:c, :m, :r) ON CONFLICT DO NOTHING"), {...})
```
3. **Timer constraint:** Only one timer runs at a time. `get_running_task_id()` in `routers/tasks.py` queries `time_entries WHERE end_at IS NULL`. Starting a new timer auto-stops the running one.
4. **SSL cert path** on Nginx uses `kasm.invixiom.com-0001` (not `kasm.invixiom.com`).
5. **No pagination.** All list views load all rows. Fine at current data volume.
6. **No CSRF protection.** Single-user system.
7. **Schema SQL files may be stale.** Always verify against live DB, not the .sql files in project-docs.
8. **Test suite not fully green.** Async event loop fixes and seed data corrections are in progress.
---
## CSS Design Tokens
When adding styles, use these existing CSS custom properties. Do not hardcode colors.
```css
/* Dark theme */
--bg: #0D0E13; --surface: #14161F; --surface2: #1A1D28; --border: #252836;
--text: #DDE1F5; --muted: #5A6080;
--accent: #4F6EF7; --accent-soft: rgba(79,110,247,.12);
--green: #22C98A; --amber: #F5A623; --red: #F05252; --purple: #9B7FF5;
/* Light theme */
--bg: #F0F2F8; --surface: #FFFFFF; --surface2: #F7F8FC; --border: #E3E6F0;
--text: #171926; --muted: #8892B0;
--accent: #4F6EF7; --accent-soft: rgba(79,110,247,.10);
--green: #10B981; --amber: #F59E0B; --red: #DC2626;
```
---
## Docker / Infrastructure
### Containers
| Container | Image | Port | Purpose |
|-----------|-------|------|---------|
| lifeos-db | postgres:16-alpine | 5432 (internal) | PostgreSQL: lifeos_dev + lifeos_prod + lifeos_test |
| lifeos-dev | lifeos-app | 8003 | Dev application (hot reload) |
| lifeos-prod | lifeos-app | 8002 | Prod application (NOT YET DEPLOYED) |
### How the Dev Container Runs
```bash
docker run -d \
--name lifeos-dev \
--network lifeos_network \
--restart unless-stopped \
--env-file .env \
-p 8003:8003 \
-v /opt/lifeos/dev/files:/opt/lifeos/files/dev \
-v /opt/lifeos/dev:/app \
lifeos-app \
uvicorn main:app --host 0.0.0.0 --port 8003 --workers 1 --reload
```
### Nginx
Host-level (not containerized). Config at `/etc/nginx/sites-available/invixiom`.
- `lifeos-dev.invixiom.com` -> `localhost:8003`
- `lifeos.invixiom.com` -> `localhost:8002` (pending)
- SSL cert at `/etc/letsencrypt/live/kasm.invixiom.com-0001/`
### Docker Network
`lifeos_network` (172.21.0.0/16) - bridges lifeos-db, lifeos-dev, lifeos-prod
---
## Test Suite
Tests use dynamic introspection. They discover routes from the live app at runtime. No hardcoded routes.
```bash
docker exec lifeos-dev bash /app/tests/run_tests.sh # Full suite
docker exec lifeos-dev bash /app/tests/run_tests.sh report # Route introspection dump
docker exec lifeos-dev bash /app/tests/run_tests.sh smoke # All GET endpoints
docker exec lifeos-dev bash /app/tests/run_tests.sh crud # All POST create/edit/delete
docker exec lifeos-dev bash /app/tests/run_tests.sh logic # Business logic
docker exec lifeos-dev bash /app/tests/run_tests.sh fast # Smoke, stop on first fail
docker exec lifeos-dev bash /app/tests/run_tests.sh -k "timer" # Keyword filter
```
**After schema changes, reset the test DB:**
```bash
docker exec lifeos-db psql -U postgres -c "DROP DATABASE IF EXISTS lifeos_test;"
docker exec lifeos-db psql -U postgres -c "CREATE DATABASE lifeos_test;"
docker exec lifeos-db pg_dump -U postgres -d lifeos_dev --schema-only -f /tmp/s.sql
docker exec lifeos-db psql -U postgres -d lifeos_test -f /tmp/s.sql -q
```
---
## Current Build State (as of 2026-03-01)
**Phase 1, Tier 3 in progress.** 2 of 6 Tier 3 features complete.
### What's Built
18 routers, 42 templates. Core CRUD for: domains, areas, projects, tasks, notes, links, focus, capture, contacts, search, admin/trash, lists, files, meetings, decisions, weblinks, appointments, time_tracking.
Working features: collapsible sidebar nav tree, task filtering/sorting/priority/status/context, daily focus, markdown notes with preview, file uploads with inline preview, global search (Cmd/K) via tsvector, admin trash with restore, time tracking with topbar timer pill, timer play/stop on task rows and detail pages.
### What's NOT Built Yet
**Tier 3 remaining (4 features, build in this order):**
1. **Processes / Process Runs** - Most complex. 4 tables: processes, process_steps, process_runs, process_run_steps. Template CRUD, run instantiation (copies steps as immutable snapshot), step completion, task generation (all_at_once vs step_by_step).
2. **Calendar view** - Unified `/calendar`: appointments (start_at) + meetings (meeting_date) + tasks (due_date). No new tables. Read-only.
3. **Time budgets** - Simple CRUD: domain_id + weekly_hours + effective_from. Dashboard overcommitment warnings.
4. **Eisenhower matrix** - Derived 2x2 grid. Priority 1-2 = Important, 3-4 = Not. Due <=7d = Urgent. Clickable quadrants.
**Tier 4 (later):** Releases/milestones, dependencies (DAG with cycle detection), task templates, note wiki-linking, note folders, bulk actions, CSV export, drag-to-reorder, reminders, dashboard metrics.
**Phase 2:** DAG visualization, MCP/AI gateway (dual: MCP + OpenAI function calling), note version history.
**Phase 3:** Mobile improvements, authentication, browser extension.
---
## Conversation History
| Session | What Was Built |
|---------|----------------|
| Pre-build | Architecture doc (50 tables, 3-phase plan), server config, schema design |
| Convo 1 | Foundation: 9 routers (domains, areas, projects, tasks, notes, links, focus, capture, contacts), base templates, sidebar, dashboard |
| Convo 2 | 7 more routers: search, admin/trash, lists, files, meetings, decisions, weblinks |
| Convo 3 | Tier 3 start: appointments CRUD, time tracking with topbar timer pill |
| Convo 4 | Timer buttons on task rows and detail pages (completes time tracking UX) |
| Test1 | Dynamic introspection-based test suite (11 files, 121 routes discovered) |
| Test2 | Test suite debugging: async event loop fixes, seed data corrections (in progress) |
---
## Database Schema Overview (48 tables in dev)
### Core Hierarchy
domains, areas, projects, tasks
### Content
notes, note_folders, lists, list_items, links, files, weblinks, weblink_folders
### CRM & Meetings
contacts, appointments, meetings, decisions
### Time Management
time_entries, time_blocks, time_budgets, daily_focus
### Processes (tables exist, CRUD not built yet)
processes, process_steps, process_runs, process_run_steps
### System
capture, context_types, reminders, dependencies, releases, milestones, task_templates, task_template_items
### Junction Tables (17)
note_projects, note_links, file_mappings, release_projects, release_domains, contact_tasks, contact_projects, contact_lists, contact_list_items, contact_appointments, contact_meetings, decision_projects, decision_contacts, meeting_tasks, process_run_tasks, folder_weblinks, note_version_history
---
## Reference Documents
These files are in the `project-docs/` folder alongside this CLAUDE.md. Consult them when you need deeper context:
| File | What It Contains |
|------|------------------|
| `lifeos-development-status-convo4.md` | **App source of truth.** Complete inventory of routers, templates, deploy patterns, what's remaining. |
| `lifeos-development-status-test1.md` | **Test source of truth.** Test architecture, seed data, introspection details. |
| `lifeos-conversation-context-convo4.md` | Quick context card for app development. |
| `lifeos-conversation-context-convo-test1.md` | Quick context card for test development. |
| `lifeos-architecture.docx` | Full system spec. 50 tables, all subsystems, UI patterns, build plan. |
| `life-os-server-config.docx` | Server infrastructure: containers, ports, networks, Nginx, SSL details. |
| `lifeos_r1_full_schema.sql` | Intended R1 schema (may not match actual DB - always verify against live). |
| `lifeos_schema_r1.sql` | Schema reference (same caveat). |
| `lifeos_r0_to_r1_migration.sql` | Migration script from old Supabase schema. |
| `lifeos-setup.sh` | Repeatable server infrastructure setup script. |
| `setup_dev_database.sh` | Dev database setup. |
| `setup_prod_database.sh` | Prod database setup. |
| `lifeos-database-backup.md` | Backup and restore commands. |
| `lifeos-v2-migration-plan.docx` | V2 migration planning. |
| `_liefos-dev-test_results1.txt` | First test deploy output (introspection verification). |
---
## Design Principles
- **KISS.** Simple wins. Complexity must earn its place.
- **Logical deletes everywhere.** No data permanently destroyed without admin confirmation.
- **Generic over specific.** Shared base code. Config-driven where possible.
- **Consistent patterns.** Every list, form, detail view follows identical conventions.
- **Search is first-class.** Every new entity gets added to global search.
- **Context travels with navigation.** Drill-down pre-fills context into create forms.
- **Single source of truth.** One system for everything.

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

250
core/base_repository.py Normal file
View File

@@ -0,0 +1,250 @@
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.
"""
import re
from uuid import UUID
from datetime import date, datetime, timezone
from typing import Any
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
_ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
_ISO_DATETIME_RE = re.compile(r"^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}")
def _coerce_value(value: Any) -> Any:
"""Convert ISO date/datetime strings to Python date/datetime objects.
asyncpg requires native Python types, not strings, for date columns."""
if not isinstance(value, str):
return value
if _ISO_DATE_RE.match(value):
try:
return date.fromisoformat(value)
except ValueError:
pass
if _ISO_DATETIME_RE.match(value):
try:
return datetime.fromisoformat(value)
except ValueError:
pass
return value
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."""
id_str = str(id)
# Validate UUID format to prevent asyncpg DataError
try:
UUID(id_str)
except (ValueError, AttributeError):
return None
query = text(f"SELECT * FROM {self.table} WHERE id = :id")
result = await self.db.execute(query, {"id": id_str})
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: _coerce_value(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 = {k: _coerce_value(v) for k, v in data.items()}
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", "parent_item_id", "release_id", "due_date", "deadline", "tags",
"context", "folder_id", "meeting_id", "completed_at",
"waiting_for_contact_id", "waiting_since", "color",
"rationale", "decided_at", "superseded_by_id",
"start_at", "end_at", "location", "agenda", "transcript", "notes_body",
"priority", "recurrence", "mime_type",
"category", "instructions", "expected_output", "estimated_days",
"contact_id", "started_at",
"weekly_hours", "effective_from",
"task_id", "meeting_id",
}
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,
}

462
deploy-tests-fix.sh Normal file
View File

@@ -0,0 +1,462 @@
#!/bin/bash
set -e
echo "=== Life OS Test Suite Fix ==="
echo "Fixes: event loop conflicts, seed data approach, search/api 422"
echo ""
# ──────────────────────────────────────────────────────────────
# Step 1: Install psycopg2-binary for sync seed data
# ──────────────────────────────────────────────────────────────
echo "[1/6] Installing psycopg2-binary..."
docker exec lifeos-dev pip install psycopg2-binary --break-system-packages -q 2>/dev/null
echo " Done"
# ──────────────────────────────────────────────────────────────
# Step 1b: Update pytest.ini for session-scoped loop
# ──────────────────────────────────────────────────────────────
echo " Updating pytest.ini..."
cat > /opt/lifeos/dev/pytest.ini << 'PYTESTINI_EOF'
[pytest]
asyncio_mode = auto
asyncio_default_fixture_loop_scope = session
testpaths = tests
addopts = -v --tb=short
PYTESTINI_EOF
# ──────────────────────────────────────────────────────────────
# Step 2: Probe the app to verify engine location
# ──────────────────────────────────────────────────────────────
echo "[2/6] Probing app structure..."
docker exec lifeos-dev python3 -c "
import os
os.environ['DATABASE_URL'] = 'postgresql+asyncpg://postgres:UCTOQDZiUhN8U@lifeos-db:5432/lifeos_test'
from core.database import engine
print(f' Engine found: {engine.url}')
print(f' Engine class: {type(engine).__name__}')
" 2>&1 || { echo "ERROR: Could not find engine in core.database"; exit 1; }
# ──────────────────────────────────────────────────────────────
# Step 3: Probe actual table columns for seed data accuracy
# ──────────────────────────────────────────────────────────────
echo "[3/6] Probing table columns for seed data..."
SEED_COLUMNS=$(docker exec lifeos-db psql -U postgres -d lifeos_dev -t -A -c "
SELECT table_name, string_agg(column_name || ':' || is_nullable || ':' || data_type, ',')
FROM information_schema.columns
WHERE table_schema='public'
AND table_name IN ('domains','areas','projects','tasks','contacts','notes','meetings','decisions','appointments','weblink_folders','lists','links','weblinks','capture_inbox','daily_focus','time_entries','files')
GROUP BY table_name
ORDER BY table_name;
")
echo " Columns retrieved for seed tables"
# Verify critical table names exist
echo " Verifying table names..."
docker exec lifeos-db psql -U postgres -d lifeos_test -t -A -c "
SELECT tablename FROM pg_tables WHERE schemaname='public'
AND tablename IN ('domains','areas','projects','tasks','contacts','notes','meetings','decisions','appointments','weblink_folders','lists','links','weblinks','capture_inbox','daily_focus','time_entries','files')
ORDER BY tablename;
"
# Check if capture table is 'capture' or 'capture_inbox'
CAPTURE_TABLE=$(docker exec lifeos-db psql -U postgres -d lifeos_test -t -A -c "
SELECT tablename FROM pg_tables WHERE schemaname='public' AND tablename LIKE 'capture%' LIMIT 1;
")
echo " Capture table name: $CAPTURE_TABLE"
# Check daily_focus vs daily_focus
FOCUS_TABLE=$(docker exec lifeos-db psql -U postgres -d lifeos_test -t -A -c "
SELECT tablename FROM pg_tables WHERE schemaname='public' AND tablename LIKE '%focus%' LIMIT 1;
")
echo " Focus table name: $FOCUS_TABLE"
# ──────────────────────────────────────────────────────────────
# Step 4: Write fixed registry.py
# ──────────────────────────────────────────────────────────────
echo "[4/6] Writing fixed registry.py..."
cat > /opt/lifeos/dev/tests/registry.py << 'REGISTRY_EOF'
"""
Route registry - imports app, runs introspection once, exposes route data.
Disposes the async engine after introspection to avoid event loop conflicts.
"""
import os
import asyncio
# Point the app at the test database BEFORE importing
os.environ["DATABASE_URL"] = "postgresql+asyncpg://postgres:UCTOQDZiUhN8U@lifeos-db:5432/lifeos_test"
from main import app
from tests.introspect import introspect_routes, classify_route
# Build route registry from live app
ROUTE_REGISTRY = introspect_routes(app)
# Classify routes into buckets for parametrized tests
GET_NO_PARAMS = [r for r in ROUTE_REGISTRY if r.method == "GET" and not r.path_params]
GET_WITH_PARAMS = [r for r in ROUTE_REGISTRY if r.method == "GET" and r.path_params]
POST_CREATE = [r for r in ROUTE_REGISTRY if r.method == "POST" and r.kind == "create"]
POST_EDIT = [r for r in ROUTE_REGISTRY if r.method == "POST" and r.kind == "edit"]
POST_DELETE = [r for r in ROUTE_REGISTRY if r.method == "POST" and r.kind == "delete"]
POST_ACTION = [r for r in ROUTE_REGISTRY if r.method == "POST" and r.kind in ("action", "toggle")]
# Map route prefixes to seed fixture keys
PREFIX_TO_SEED = {
"/domains": "domain",
"/areas": "area",
"/projects": "project",
"/tasks": "task",
"/notes": "note",
"/links": "link",
"/contacts": "contact",
"/lists": "list",
"/meetings": "meeting",
"/decisions": "decision",
"/weblinks": "weblink",
"/weblinks/folders": "weblink_folder",
"/appointments": "appointment",
"/focus": "focus",
"/capture": "capture",
"/time": "task",
"/files": None,
"/admin/trash": None,
}
def resolve_path(path_template, seeds):
"""Replace {id} placeholders with real seed UUIDs."""
import re
result = path_template
for param in re.findall(r"\{(\w+)\}", path_template):
# Find prefix for this route
for prefix, seed_key in sorted(PREFIX_TO_SEED.items(), key=lambda x: -len(x[0])):
if path_template.startswith(prefix) and seed_key and seed_key in seeds:
result = result.replace(f"{{{param}}}", str(seeds[seed_key]))
break
return result
# CRITICAL: Dispose the async engine created at import time.
# It was bound to whatever event loop existed during collection.
# When tests run, pytest-asyncio creates a NEW event loop.
# The engine will lazily recreate its connection pool on that new loop.
try:
from core.database import engine
loop = asyncio.new_event_loop()
loop.run_until_complete(engine.dispose())
loop.close()
except Exception:
pass # If disposal fails, tests will still try to proceed
REGISTRY_EOF
echo " registry.py written"
# ──────────────────────────────────────────────────────────────
# Step 5: Write fixed conftest.py
# ──────────────────────────────────────────────────────────────
echo "[5/6] Writing fixed conftest.py..."
cat > /opt/lifeos/dev/tests/conftest.py << 'CONFTEST_EOF'
"""
Test fixtures - uses psycopg2 (sync) for seed data to avoid event loop conflicts.
Seeds are session-scoped and committed so the app can see them via its own engine.
"""
import asyncio
import uuid
import pytest
import psycopg2
from httpx import AsyncClient, ASGITransport
from tests.registry import app
TEST_DB_DSN = "host=lifeos-db port=5432 dbname=lifeos_test user=postgres password=UCTOQDZiUhN8U"
# ── Fixed seed UUIDs (stable across test runs) ──────────────
SEED_IDS = {
"domain": "a0000000-0000-0000-0000-000000000001",
"area": "a0000000-0000-0000-0000-000000000002",
"project": "a0000000-0000-0000-0000-000000000003",
"task": "a0000000-0000-0000-0000-000000000004",
"contact": "a0000000-0000-0000-0000-000000000005",
"note": "a0000000-0000-0000-0000-000000000006",
"meeting": "a0000000-0000-0000-0000-000000000007",
"decision": "a0000000-0000-0000-0000-000000000008",
"appointment": "a0000000-0000-0000-0000-000000000009",
"weblink_folder": "a0000000-0000-0000-0000-00000000000a",
"list": "a0000000-0000-0000-0000-00000000000b",
"link": "a0000000-0000-0000-0000-00000000000c",
"weblink": "a0000000-0000-0000-0000-00000000000d",
"capture": "a0000000-0000-0000-0000-00000000000e",
"focus": "a0000000-0000-0000-0000-00000000000f",
}
# ── Session-scoped event loop ───────────────────────────────
# All async tests share one loop so the app's engine pool stays valid.
@pytest.fixture(scope="session")
def event_loop():
loop = asyncio.new_event_loop()
yield loop
loop.close()
# ── Sync DB connection for seed management ──────────────────
@pytest.fixture(scope="session")
def sync_conn():
conn = psycopg2.connect(TEST_DB_DSN)
conn.autocommit = False
yield conn
conn.close()
# ── Seed data (session-scoped, committed) ───────────────────
@pytest.fixture(scope="session")
def all_seeds(sync_conn):
"""Insert all seed data once. Committed so the app's engine can see it."""
cur = sync_conn.cursor()
d = SEED_IDS
try:
# Domain
cur.execute("""
INSERT INTO domains (id, name, color, description, sort_order, is_deleted, created_at, updated_at)
VALUES (%s, 'Test Domain', '#FF5733', 'Auto test domain', 0, false, now(), now())
ON CONFLICT (id) DO NOTHING
""", (d["domain"],))
# Area
cur.execute("""
INSERT INTO areas (id, name, domain_id, description, status, sort_order, is_deleted, created_at, updated_at)
VALUES (%s, 'Test Area', %s, 'Auto test area', 'active', 0, false, now(), now())
ON CONFLICT (id) DO NOTHING
""", (d["area"], d["domain"]))
# Project
cur.execute("""
INSERT INTO projects (id, name, domain_id, area_id, description, status, priority, sort_order, is_deleted, created_at, updated_at)
VALUES (%s, 'Test Project', %s, %s, 'Auto test project', 'active', 2, 0, false, now(), now())
ON CONFLICT (id) DO NOTHING
""", (d["project"], d["domain"], d["area"]))
# Task
cur.execute("""
INSERT INTO tasks (id, title, domain_id, project_id, description, priority, status, sort_order, is_deleted, created_at, updated_at)
VALUES (%s, 'Test Task', %s, %s, 'Auto test task', 2, 'todo', 0, false, now(), now())
ON CONFLICT (id) DO NOTHING
""", (d["task"], d["domain"], d["project"]))
# Contact
cur.execute("""
INSERT INTO contacts (id, first_name, last_name, company, email, is_deleted, created_at, updated_at)
VALUES (%s, 'Test', 'Contact', 'TestCorp', 'test@example.com', false, now(), now())
ON CONFLICT (id) DO NOTHING
""", (d["contact"],))
# Note
cur.execute("""
INSERT INTO notes (id, title, domain_id, body, content_format, is_deleted, created_at, updated_at)
VALUES (%s, 'Test Note', %s, 'Test body content', 'markdown', false, now(), now())
ON CONFLICT (id) DO NOTHING
""", (d["note"], d["domain"]))
# Meeting
cur.execute("""
INSERT INTO meetings (id, title, meeting_date, status, is_deleted, created_at, updated_at)
VALUES (%s, 'Test Meeting', '2025-06-15', 'scheduled', false, now(), now())
ON CONFLICT (id) DO NOTHING
""", (d["meeting"],))
# Decision
cur.execute("""
INSERT INTO decisions (id, title, status, impact, is_deleted, created_at, updated_at)
VALUES (%s, 'Test Decision', 'decided', 'high', false, now(), now())
ON CONFLICT (id) DO NOTHING
""", (d["decision"],))
# Appointment
cur.execute("""
INSERT INTO appointments (id, title, start_at, end_at, all_day, is_deleted, created_at, updated_at)
VALUES (%s, 'Test Appointment', '2025-06-15 10:00:00', '2025-06-15 11:00:00', false, false, now(), now())
ON CONFLICT (id) DO NOTHING
""", (d["appointment"],))
# Weblink folder
cur.execute("""
INSERT INTO weblink_folders (id, name, is_deleted, created_at, updated_at)
VALUES (%s, 'Test Folder', false, now(), now())
ON CONFLICT (id) DO NOTHING
""", (d["weblink_folder"],))
# List
cur.execute("""
INSERT INTO lists (id, name, domain_id, project_id, list_type, is_deleted, created_at, updated_at)
VALUES (%s, 'Test List', %s, %s, 'checklist', false, now(), now())
ON CONFLICT (id) DO NOTHING
""", (d["list"], d["domain"], d["project"]))
# Link
cur.execute("""
INSERT INTO links (id, label, url, domain_id, is_deleted, created_at, updated_at)
VALUES (%s, 'Test Link', 'https://example.com', %s, false, now(), now())
ON CONFLICT (id) DO NOTHING
""", (d["link"], d["domain"]))
# Weblink
cur.execute("""
INSERT INTO weblinks (id, label, url, folder_id, is_deleted, created_at, updated_at)
VALUES (%s, 'Test Weblink', 'https://example.com/wl', %s, false, now(), now())
ON CONFLICT (id) DO NOTHING
""", (d["weblink"], d["weblink_folder"]))
# Capture
cur.execute("""
INSERT INTO capture_inbox (id, raw_text, status, is_deleted, created_at, updated_at)
VALUES (%s, 'Test capture item', 'pending', false, now(), now())
ON CONFLICT (id) DO NOTHING
""", (d["capture"],))
# Daily focus
cur.execute("""
INSERT INTO daily_focus (id, task_id, focus_date, is_completed, created_at)
VALUES (%s, %s, CURRENT_DATE, false, now())
ON CONFLICT (id) DO NOTHING
""", (d["focus"], d["task"]))
sync_conn.commit()
except Exception as e:
sync_conn.rollback()
raise RuntimeError(f"Seed data insertion failed: {e}") from e
yield d
# Cleanup: delete all seed data (reverse dependency order)
try:
cur.execute("DELETE FROM daily_focus WHERE id = %s", (d["focus"],))
cur.execute("DELETE FROM capture_inbox WHERE id = %s", (d["capture"],))
cur.execute("DELETE FROM weblinks WHERE id = %s", (d["weblink"],))
cur.execute("DELETE FROM links WHERE id = %s", (d["link"],))
cur.execute("DELETE FROM lists WHERE id = %s", (d["list"],))
cur.execute("DELETE FROM weblink_folders WHERE id = %s", (d["weblink_folder"],))
cur.execute("DELETE FROM appointments WHERE id = %s", (d["appointment"],))
cur.execute("DELETE FROM decisions WHERE id = %s", (d["decision"],))
cur.execute("DELETE FROM meetings WHERE id = %s", (d["meeting"],))
cur.execute("DELETE FROM notes WHERE id = %s", (d["note"],))
cur.execute("DELETE FROM contacts WHERE id = %s", (d["contact"],))
cur.execute("DELETE FROM tasks WHERE id = %s", (d["task"],))
cur.execute("DELETE FROM projects WHERE id = %s", (d["project"],))
cur.execute("DELETE FROM areas WHERE id = %s", (d["area"],))
cur.execute("DELETE FROM domains WHERE id = %s", (d["domain"],))
sync_conn.commit()
except Exception:
sync_conn.rollback()
finally:
cur.close()
# ── HTTP client (function-scoped) ───────────────────────────
@pytest.fixture
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
CONFTEST_EOF
echo " conftest.py written"
# ──────────────────────────────────────────────────────────────
# Step 6: Write fixed test_smoke_dynamic.py
# ──────────────────────────────────────────────────────────────
echo "[6/6] Writing fixed test_smoke_dynamic.py..."
cat > /opt/lifeos/dev/tests/test_smoke_dynamic.py << 'SMOKE_EOF'
"""
Dynamic smoke tests - auto-parametrized from introspected routes.
Tests that all GET endpoints return 200 (or acceptable alternatives).
"""
import pytest
from tests.registry import (
GET_NO_PARAMS, GET_WITH_PARAMS,
resolve_path, PREFIX_TO_SEED, ROUTE_REGISTRY,
)
# Routes that require query params to avoid 422
ROUTES_NEEDING_QUERY = {
"/search/api": "?q=test",
}
# Routes that may legitimately return non-200 without full context
ACCEPTABLE_CODES = {200, 307, 308}
# ── Test 1: All GET endpoints without path params ───────────
@pytest.mark.parametrize(
"path",
[r.path for r in GET_NO_PARAMS],
ids=[f"GET {r.path}" for r in GET_NO_PARAMS],
)
async def test_get_no_params_returns_200(client, path):
"""Every GET endpoint without path params should return 200."""
url = path
# Append required query params if needed
for route_prefix, query_string in ROUTES_NEEDING_QUERY.items():
if path == route_prefix or path.rstrip("/") == route_prefix.rstrip("/"):
url = path + query_string
break
r = await client.get(url, follow_redirects=True)
assert r.status_code in ACCEPTABLE_CODES or r.status_code == 200, \
f"GET {url} returned {r.status_code}"
# ── Test 2: All GET endpoints with valid seed IDs ───────────
@pytest.mark.parametrize(
"path_template",
[r.path for r in GET_WITH_PARAMS],
ids=[f"GET {r.path}" for r in GET_WITH_PARAMS],
)
async def test_get_with_valid_id_returns_200(client, all_seeds, path_template):
"""GET endpoints with a valid seed UUID should return 200."""
path = resolve_path(path_template, all_seeds)
# Skip if we couldn't resolve (no seed for this prefix)
if "{" in path:
pytest.skip(f"No seed mapping for {path_template}")
r = await client.get(path, follow_redirects=True)
assert r.status_code == 200, f"GET {path} returned {r.status_code}"
# ── Test 3: GET with fake UUID returns 404 ──────────────────
FAKE_UUID = "00000000-0000-0000-0000-000000000000"
# Build fake-ID test cases from routes that have path params
_fake_id_cases = []
for r in GET_WITH_PARAMS:
import re
fake_path = re.sub(r"\{[^}]+\}", FAKE_UUID, r.path)
_fake_id_cases.append((fake_path, r.path))
@pytest.mark.parametrize(
"path,template",
_fake_id_cases if _fake_id_cases else [pytest.param("", "", marks=pytest.mark.skip)],
ids=[f"404 {c[1]}" for c in _fake_id_cases] if _fake_id_cases else ["NOTSET"],
)
async def test_get_with_fake_id_returns_404(client, path, template):
"""GET endpoints with a nonexistent UUID should return 404."""
r = await client.get(path, follow_redirects=True)
assert r.status_code in (404, 302, 303), \
f"GET {path} returned {r.status_code}, expected 404 or redirect"
SMOKE_EOF
echo " test_smoke_dynamic.py written"
# ──────────────────────────────────────────────────────────────
# Verify deployment
# ──────────────────────────────────────────────────────────────
echo ""
echo "=== Fix deployed ==="
echo ""
echo "Changes made:"
echo " 1. Installed psycopg2-binary (sync DB driver for seed data)"
echo " 2. registry.py: disposes async engine after introspection"
echo " 3. conftest.py: session-scoped event loop, psycopg2 seeds, fixed UUIDs"
echo " 4. test_smoke_dynamic.py: handles /search/api query param, better assertions"
echo ""
echo "Now run:"
echo " docker exec lifeos-dev bash /app/tests/run_tests.sh smoke"
echo ""
echo "If smoke tests pass, run:"
echo " docker exec lifeos-dev bash /app/tests/run_tests.sh crud"
echo " docker exec lifeos-dev bash /app/tests/run_tests.sh logic"
echo " docker exec lifeos-dev bash /app/tests/run_tests.sh # full suite"

1564
deploy-tests.sh Normal file

File diff suppressed because it is too large Load Diff

693
deploy-timer-buttons.sh Normal file
View File

@@ -0,0 +1,693 @@
#!/bin/bash
# Deploy timer buttons on task rows and task detail page
# Run from server: bash deploy-timer-buttons.sh
set -e
cd /opt/lifeos/dev
echo "=== Deploying timer buttons ==="
# 1. Backup originals
cp routers/tasks.py routers/tasks.py.bak
cp templates/tasks.html templates/tasks.html.bak
cp templates/task_detail.html templates/task_detail.html.bak
cp static/style.css static/style.css.bak
echo "[OK] Backups created"
# 2. Write routers/tasks.py
cat > routers/tasks.py << 'TASKSROUTER'
"""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")
async def get_running_task_id(db: AsyncSession) -> Optional[str]:
"""Get the task_id of the currently running timer, if any."""
result = await db.execute(text(
"SELECT task_id FROM time_entries WHERE end_at IS NULL AND is_deleted = false LIMIT 1"
))
row = result.first()
return str(row.task_id) if row else None
@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]
running_task_id = await get_running_task_id(db)
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,
"running_task_id": running_task_id,
"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]
running_task_id = await get_running_task_id(db)
return templates.TemplateResponse("task_detail.html", {
"request": request, "sidebar": sidebar, "item": item,
"domain": domain, "project": project, "parent": parent,
"subtasks": subtasks,
"running_task_id": running_task_id,
"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, request: Request, 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)
TASKSROUTER
echo "[OK] routers/tasks.py"
# 3. Write templates/tasks.html
cat > templates/tasks.html << 'TASKSHTML'
{% 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'] }} {{ 'timer-active' if running_task_id and item.id|string == running_task_id }}">
<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>
{% if item.status not in ['done', 'cancelled'] %}
<div class="row-timer">
{% if running_task_id and item.id|string == running_task_id %}
<form action="/time/stop" method="post" style="display:inline">
<button type="submit" class="timer-btn timer-btn-stop" title="Stop timer">&#9632;</button>
</form>
{% else %}
<form action="/time/start" method="post" style="display:inline">
<input type="hidden" name="task_id" value="{{ item.id }}">
<button type="submit" class="timer-btn timer-btn-play" title="Start timer">&#9654;</button>
</form>
{% endif %}
</div>
{% endif %}
<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 %}
TASKSHTML
echo "[OK] templates/tasks.html"
# 4. Write templates/task_detail.html
cat > templates/task_detail.html << 'DETAILHTML'
{% 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">
{% if item.status not in ['done', 'cancelled'] %}
{% if running_task_id and item.id|string == running_task_id %}
<form action="/time/stop" method="post" style="display:inline">
<button class="btn btn-sm timer-detail-btn timer-detail-stop" title="Stop timer">&#9632; Stop Timer</button>
</form>
{% else %}
<form action="/time/start" method="post" style="display:inline">
<input type="hidden" name="task_id" value="{{ item.id }}">
<button class="btn btn-sm timer-detail-btn timer-detail-play" title="Start timer">&#9654; Start Timer</button>
</form>
{% endif %}
{% endif %}
<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 %}
DETAILHTML
echo "[OK] templates/task_detail.html"
# 5. Append timer button CSS to style.css
cat >> static/style.css << 'TIMERCSS'
/* Timer buttons on task rows */
.row-timer {
display: flex;
align-items: center;
flex-shrink: 0;
}
.timer-btn {
width: 24px;
height: 24px;
border: none;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
line-height: 1;
padding: 0;
transition: background 0.15s, transform 0.1s;
}
.timer-btn:hover {
transform: scale(1.15);
}
.timer-btn-play {
background: var(--green, #22c55e)22;
color: var(--green, #22c55e);
}
.timer-btn-play:hover {
background: var(--green, #22c55e)44;
}
.timer-btn-stop {
background: var(--red, #ef4444)22;
color: var(--red, #ef4444);
}
.timer-btn-stop:hover {
background: var(--red, #ef4444)44;
}
/* Highlight row with active timer */
.list-row.timer-active {
border-left: 3px solid var(--green, #22c55e);
background: var(--green, #22c55e)08;
}
/* Timer button on task detail page */
.timer-detail-btn {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 13px;
}
.timer-detail-play {
background: var(--green, #22c55e)18;
color: var(--green, #22c55e);
border: 1px solid var(--green, #22c55e)44;
}
.timer-detail-play:hover {
background: var(--green, #22c55e)33;
}
.timer-detail-stop {
background: var(--red, #ef4444)18;
color: var(--red, #ef4444);
border: 1px solid var(--red, #ef4444)44;
}
.timer-detail-stop:hover {
background: var(--red, #ef4444)33;
}
TIMERCSS
echo "[OK] CSS appended to static/style.css"
# 6. Clean up backups
rm -f routers/tasks.py.bak templates/tasks.html.bak templates/task_detail.html.bak static/style.css.bak
echo "[OK] Backups cleaned"
# 7. Check hot reload
echo ""
echo "=== Checking container ==="
sleep 2
docker logs lifeos-dev --tail 5
echo ""
echo "=== Deploy complete ==="
echo "Test: visit /tasks, click play on a task, verify topbar pill + green row highlight"

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

View File

@@ -0,0 +1,979 @@
-- =============================================================================
-- Life OS - Release 1 COMPLETE Schema
-- Self-hosted PostgreSQL 16 on defiant-01 (Hetzner)
-- Database: lifeos_dev
-- Generated from Architecture Design Document v2.0
-- =============================================================================
-- Extensions
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- =============================================================================
-- LOOKUP TABLE: Context Types
-- =============================================================================
CREATE TABLE context_types (
id SERIAL PRIMARY KEY,
value TEXT NOT NULL UNIQUE,
label TEXT NOT NULL,
description TEXT,
is_system BOOLEAN NOT NULL DEFAULT true,
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- CORE HIERARCHY
-- =============================================================================
CREATE TABLE domains (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
color TEXT,
description TEXT,
icon TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE areas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
domain_id UUID NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
icon TEXT,
color TEXT,
status TEXT NOT NULL DEFAULT 'active',
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
domain_id UUID NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
area_id UUID REFERENCES areas(id) ON DELETE SET NULL,
name TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'active',
priority INTEGER NOT NULL DEFAULT 3,
start_date DATE,
target_date DATE,
completed_at TIMESTAMPTZ,
color TEXT,
tags TEXT[],
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Forward-declare releases for tasks.release_id FK
CREATE TABLE releases (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
version_label TEXT,
description TEXT,
status TEXT NOT NULL DEFAULT 'planned',
target_date DATE,
released_at DATE,
release_notes TEXT,
tags TEXT[],
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Forward-declare contacts for tasks.waiting_for_contact_id FK
CREATE TABLE contacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
first_name TEXT NOT NULL,
last_name TEXT,
company TEXT,
role TEXT,
email TEXT,
phone TEXT,
notes TEXT,
tags TEXT[],
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
domain_id UUID REFERENCES domains(id) ON DELETE CASCADE,
area_id UUID REFERENCES areas(id) ON DELETE SET NULL,
project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
release_id UUID REFERENCES releases(id) ON DELETE SET NULL,
parent_id UUID REFERENCES tasks(id) ON DELETE SET NULL,
title TEXT NOT NULL,
description TEXT,
priority INTEGER NOT NULL DEFAULT 3,
status TEXT NOT NULL DEFAULT 'open',
due_date DATE,
deadline TIMESTAMPTZ,
recurrence TEXT,
estimated_minutes INTEGER,
energy_required TEXT,
context TEXT,
is_custom_context BOOLEAN NOT NULL DEFAULT false,
waiting_for_contact_id UUID REFERENCES contacts(id) ON DELETE SET NULL,
waiting_since DATE,
import_batch_id UUID,
tags TEXT[],
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- KNOWLEDGE MANAGEMENT
-- =============================================================================
CREATE TABLE note_folders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
parent_id UUID REFERENCES note_folders(id) ON DELETE CASCADE,
name TEXT NOT NULL,
auto_generated BOOLEAN NOT NULL DEFAULT false,
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Forward-declare meetings for notes.meeting_id FK
CREATE TABLE meetings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
parent_id UUID REFERENCES meetings(id) ON DELETE SET NULL,
title TEXT NOT NULL,
meeting_date DATE NOT NULL,
start_at TIMESTAMPTZ,
end_at TIMESTAMPTZ,
location TEXT,
status TEXT NOT NULL DEFAULT 'scheduled',
priority INTEGER,
recurrence TEXT,
agenda TEXT,
transcript TEXT,
notes_body TEXT,
tags TEXT[],
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
domain_id UUID REFERENCES domains(id) ON DELETE CASCADE,
project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
folder_id UUID REFERENCES note_folders(id) ON DELETE SET NULL,
meeting_id UUID REFERENCES meetings(id) ON DELETE SET NULL,
title TEXT NOT NULL,
body TEXT,
content_format TEXT NOT NULL DEFAULT 'rich',
is_meeting_note BOOLEAN NOT NULL DEFAULT false,
tags TEXT[],
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE decisions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
rationale TEXT,
status TEXT NOT NULL DEFAULT 'proposed',
impact TEXT NOT NULL DEFAULT 'medium',
decided_at DATE,
meeting_id UUID REFERENCES meetings(id) ON DELETE SET NULL,
superseded_by_id UUID REFERENCES decisions(id) ON DELETE SET NULL,
tags TEXT[],
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE lists (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
domain_id UUID REFERENCES domains(id) ON DELETE CASCADE,
area_id UUID REFERENCES areas(id) ON DELETE SET NULL,
project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
name TEXT NOT NULL,
list_type TEXT NOT NULL DEFAULT 'checklist',
description TEXT,
tags TEXT[],
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE list_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
list_id UUID NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
parent_item_id UUID REFERENCES list_items(id) ON DELETE SET NULL,
content TEXT NOT NULL,
completed BOOLEAN NOT NULL DEFAULT false,
completed_at TIMESTAMPTZ,
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
domain_id UUID REFERENCES domains(id) ON DELETE CASCADE,
area_id UUID REFERENCES areas(id) ON DELETE SET NULL,
project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
label TEXT NOT NULL,
url TEXT NOT NULL,
description TEXT,
tags TEXT[],
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
filename TEXT NOT NULL,
original_filename TEXT NOT NULL,
storage_path TEXT NOT NULL,
mime_type TEXT,
size_bytes INTEGER,
description TEXT,
tags TEXT[],
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- SYSTEM LEVEL: Appointments
-- =============================================================================
CREATE TABLE appointments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
description TEXT,
location TEXT,
start_at TIMESTAMPTZ NOT NULL,
end_at TIMESTAMPTZ,
all_day BOOLEAN NOT NULL DEFAULT false,
recurrence TEXT,
tags TEXT[],
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- SYSTEM LEVEL: Milestones
-- =============================================================================
CREATE TABLE milestones (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
release_id UUID REFERENCES releases(id) ON DELETE SET NULL,
project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
name TEXT NOT NULL,
target_date DATE NOT NULL,
completed_at DATE,
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- SYSTEM LEVEL: Processes
-- =============================================================================
CREATE TABLE processes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT,
process_type TEXT NOT NULL DEFAULT 'checklist',
category TEXT,
status TEXT NOT NULL DEFAULT 'draft',
tags TEXT[],
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE process_steps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
process_id UUID NOT NULL REFERENCES processes(id) ON DELETE CASCADE,
title TEXT NOT NULL,
instructions TEXT,
expected_output TEXT,
estimated_days INTEGER,
context TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE process_runs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
process_id UUID NOT NULL REFERENCES processes(id) ON DELETE CASCADE,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'not_started',
process_type TEXT NOT NULL,
task_generation TEXT NOT NULL DEFAULT 'all_at_once',
project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
contact_id UUID REFERENCES contacts(id) ON DELETE SET NULL,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE process_run_steps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
run_id UUID NOT NULL REFERENCES process_runs(id) ON DELETE CASCADE,
title TEXT NOT NULL,
instructions TEXT,
status TEXT NOT NULL DEFAULT 'pending',
completed_by_id UUID REFERENCES contacts(id) ON DELETE SET NULL,
completed_at TIMESTAMPTZ,
notes TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- SYSTEM LEVEL: Daily Focus
-- =============================================================================
CREATE TABLE daily_focus (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
focus_date DATE NOT NULL,
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
slot INTEGER,
completed BOOLEAN NOT NULL DEFAULT false,
note TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- SYSTEM LEVEL: Capture Queue
-- =============================================================================
CREATE TABLE capture (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
raw_text TEXT NOT NULL,
processed BOOLEAN NOT NULL DEFAULT false,
converted_to_type TEXT,
converted_to_id UUID,
area_id UUID REFERENCES areas(id) ON DELETE SET NULL,
project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
list_id UUID REFERENCES lists(id) ON DELETE SET NULL,
import_batch_id UUID,
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- SYSTEM LEVEL: Task Templates
-- =============================================================================
CREATE TABLE task_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT,
priority INTEGER,
estimated_minutes INTEGER,
energy_required TEXT,
context TEXT,
tags TEXT[],
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE task_template_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
template_id UUID NOT NULL REFERENCES task_templates(id) ON DELETE CASCADE,
title TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- TIME MANAGEMENT
-- =============================================================================
CREATE TABLE time_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
start_at TIMESTAMPTZ NOT NULL,
end_at TIMESTAMPTZ,
duration_minutes INTEGER,
notes TEXT,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE time_blocks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
task_id UUID REFERENCES tasks(id) ON DELETE SET NULL,
title TEXT NOT NULL,
context TEXT,
energy TEXT,
start_at TIMESTAMPTZ NOT NULL,
end_at TIMESTAMPTZ NOT NULL,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE time_budgets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
domain_id UUID NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
weekly_hours DECIMAL NOT NULL,
effective_from DATE NOT NULL,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- SYSTEM LEVEL: Weblink Directory
-- =============================================================================
CREATE TABLE weblink_folders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
parent_id UUID REFERENCES weblink_folders(id) ON DELETE CASCADE,
name TEXT NOT NULL,
auto_generated BOOLEAN NOT NULL DEFAULT false,
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE weblinks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
label TEXT NOT NULL,
url TEXT NOT NULL,
description TEXT,
tags TEXT[],
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- SYSTEM LEVEL: Reminders (polymorphic)
-- =============================================================================
CREATE TABLE reminders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
entity_type TEXT NOT NULL,
entity_id UUID NOT NULL,
remind_at TIMESTAMPTZ NOT NULL,
note TEXT,
delivered BOOLEAN NOT NULL DEFAULT false,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- UNIVERSAL: Dependencies (polymorphic DAG)
-- =============================================================================
CREATE TABLE dependencies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
blocker_type TEXT NOT NULL,
blocker_id UUID NOT NULL,
dependent_type TEXT NOT NULL,
dependent_id UUID NOT NULL,
dependency_type TEXT NOT NULL DEFAULT 'finish_to_start',
lag_days INTEGER NOT NULL DEFAULT 0,
note TEXT,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (blocker_type, blocker_id, dependent_type, dependent_id, dependency_type),
CHECK (NOT (blocker_type = dependent_type AND blocker_id = dependent_id))
);
-- =============================================================================
-- JUNCTION TABLES
-- =============================================================================
-- Notes <-> Projects (M2M)
CREATE TABLE note_projects (
note_id UUID NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
is_primary BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (note_id, project_id)
);
-- Notes <-> Notes (wiki graph)
CREATE TABLE note_links (
source_note_id UUID NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
target_note_id UUID NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (source_note_id, target_note_id)
);
-- Files <-> any entity (polymorphic M2M)
CREATE TABLE file_mappings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
file_id UUID NOT NULL REFERENCES files(id) ON DELETE CASCADE,
context_type TEXT NOT NULL,
context_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (file_id, context_type, context_id)
);
-- Releases <-> Projects (M2M)
CREATE TABLE release_projects (
release_id UUID NOT NULL REFERENCES releases(id) ON DELETE CASCADE,
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (release_id, project_id)
);
-- Releases <-> Domains (M2M)
CREATE TABLE release_domains (
release_id UUID NOT NULL REFERENCES releases(id) ON DELETE CASCADE,
domain_id UUID NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (release_id, domain_id)
);
-- Contacts <-> Tasks
CREATE TABLE contact_tasks (
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
role TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (contact_id, task_id)
);
-- Contacts <-> Projects
CREATE TABLE contact_projects (
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
role TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (contact_id, project_id)
);
-- Contacts <-> Lists
CREATE TABLE contact_lists (
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
list_id UUID NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
role TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (contact_id, list_id)
);
-- Contacts <-> List Items
CREATE TABLE contact_list_items (
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
list_item_id UUID NOT NULL REFERENCES list_items(id) ON DELETE CASCADE,
role TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (contact_id, list_item_id)
);
-- Contacts <-> Appointments
CREATE TABLE contact_appointments (
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
appointment_id UUID NOT NULL REFERENCES appointments(id) ON DELETE CASCADE,
role TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (contact_id, appointment_id)
);
-- Contacts <-> Meetings
CREATE TABLE contact_meetings (
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
meeting_id UUID NOT NULL REFERENCES meetings(id) ON DELETE CASCADE,
role TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (contact_id, meeting_id)
);
-- Decisions <-> Projects
CREATE TABLE decision_projects (
decision_id UUID NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (decision_id, project_id)
);
-- Decisions <-> Contacts
CREATE TABLE decision_contacts (
decision_id UUID NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
role TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (decision_id, contact_id)
);
-- Meetings <-> Tasks
CREATE TABLE meeting_tasks (
meeting_id UUID NOT NULL REFERENCES meetings(id) ON DELETE CASCADE,
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
source TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (meeting_id, task_id)
);
-- Process Run Steps <-> Tasks
CREATE TABLE process_run_tasks (
run_step_id UUID NOT NULL REFERENCES process_run_steps(id) ON DELETE CASCADE,
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (run_step_id, task_id)
);
-- Weblinks <-> Folders (M2M)
CREATE TABLE folder_weblinks (
folder_id UUID NOT NULL REFERENCES weblink_folders(id) ON DELETE CASCADE,
weblink_id UUID NOT NULL REFERENCES weblinks(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (folder_id, weblink_id)
);
-- =============================================================================
-- INDEXES
-- =============================================================================
-- Sort order indexes
CREATE INDEX idx_domains_sort ON domains(sort_order);
CREATE INDEX idx_areas_sort ON areas(domain_id, sort_order);
CREATE INDEX idx_projects_sort ON projects(domain_id, sort_order);
CREATE INDEX idx_projects_area_sort ON projects(area_id, sort_order);
CREATE INDEX idx_tasks_project_sort ON tasks(project_id, sort_order);
CREATE INDEX idx_tasks_parent_sort ON tasks(parent_id, sort_order);
CREATE INDEX idx_tasks_domain_sort ON tasks(domain_id, sort_order);
CREATE INDEX idx_list_items_sort ON list_items(list_id, sort_order);
CREATE INDEX idx_list_items_parent_sort ON list_items(parent_item_id, sort_order);
CREATE INDEX idx_weblink_folders_sort ON weblink_folders(parent_id, sort_order);
-- Lookup indexes
CREATE INDEX idx_tasks_status ON tasks(status);
CREATE INDEX idx_tasks_due_date ON tasks(due_date);
CREATE INDEX idx_tasks_priority ON tasks(priority);
CREATE INDEX idx_projects_status ON projects(status);
CREATE INDEX idx_daily_focus_date ON daily_focus(focus_date);
CREATE INDEX idx_appointments_start ON appointments(start_at);
CREATE INDEX idx_capture_processed ON capture(processed);
CREATE INDEX idx_file_mappings_context ON file_mappings(context_type, context_id);
CREATE INDEX idx_dependencies_blocker ON dependencies(blocker_type, blocker_id);
CREATE INDEX idx_dependencies_dependent ON dependencies(dependent_type, dependent_id);
CREATE INDEX idx_reminders_entity ON reminders(entity_type, entity_id);
CREATE INDEX idx_time_entries_task ON time_entries(task_id);
CREATE INDEX idx_meetings_date ON meetings(meeting_date);
-- Full-text search GIN indexes
CREATE INDEX idx_domains_search ON domains USING GIN(search_vector);
CREATE INDEX idx_areas_search ON areas USING GIN(search_vector);
CREATE INDEX idx_projects_search ON projects USING GIN(search_vector);
CREATE INDEX idx_tasks_search ON tasks USING GIN(search_vector);
CREATE INDEX idx_notes_search ON notes USING GIN(search_vector);
CREATE INDEX idx_contacts_search ON contacts USING GIN(search_vector);
CREATE INDEX idx_meetings_search ON meetings USING GIN(search_vector);
CREATE INDEX idx_decisions_search ON decisions USING GIN(search_vector);
CREATE INDEX idx_lists_search ON lists USING GIN(search_vector);
CREATE INDEX idx_links_search ON links USING GIN(search_vector);
CREATE INDEX idx_files_search ON files USING GIN(search_vector);
CREATE INDEX idx_weblinks_search ON weblinks USING GIN(search_vector);
CREATE INDEX idx_processes_search ON processes USING GIN(search_vector);
CREATE INDEX idx_appointments_search ON appointments USING GIN(search_vector);
-- =============================================================================
-- SEARCH VECTOR TRIGGERS
-- =============================================================================
CREATE OR REPLACE FUNCTION update_search_vector() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('pg_catalog.english',
coalesce(NEW.title, '') || ' ' ||
coalesce(NEW.description, '') || ' ' ||
coalesce(NEW.name, '') || ' ' ||
coalesce(array_to_string(NEW.tags, ' '), '')
);
RETURN NEW;
EXCEPTION WHEN undefined_column THEN
-- Fallback for tables with different column names
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Per-table triggers with correct columns
CREATE OR REPLACE FUNCTION update_domains_search() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('pg_catalog.english', coalesce(NEW.name, ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_domains_search BEFORE INSERT OR UPDATE ON domains
FOR EACH ROW EXECUTE FUNCTION update_domains_search();
CREATE OR REPLACE FUNCTION update_areas_search() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('pg_catalog.english',
coalesce(NEW.name, '') || ' ' || coalesce(NEW.description, ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_areas_search BEFORE INSERT OR UPDATE ON areas
FOR EACH ROW EXECUTE FUNCTION update_areas_search();
CREATE OR REPLACE FUNCTION update_projects_search() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('pg_catalog.english',
coalesce(NEW.name, '') || ' ' || coalesce(NEW.description, '') || ' ' ||
coalesce(array_to_string(NEW.tags, ' '), ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_projects_search BEFORE INSERT OR UPDATE ON projects
FOR EACH ROW EXECUTE FUNCTION update_projects_search();
CREATE OR REPLACE FUNCTION update_tasks_search() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('pg_catalog.english',
coalesce(NEW.title, '') || ' ' || coalesce(NEW.description, '') || ' ' ||
coalesce(array_to_string(NEW.tags, ' '), ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_tasks_search BEFORE INSERT OR UPDATE ON tasks
FOR EACH ROW EXECUTE FUNCTION update_tasks_search();
CREATE OR REPLACE FUNCTION update_notes_search() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('pg_catalog.english',
coalesce(NEW.title, '') || ' ' || coalesce(NEW.body, '') || ' ' ||
coalesce(array_to_string(NEW.tags, ' '), ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_notes_search BEFORE INSERT OR UPDATE ON notes
FOR EACH ROW EXECUTE FUNCTION update_notes_search();
CREATE OR REPLACE FUNCTION update_contacts_search() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('pg_catalog.english',
coalesce(NEW.first_name, '') || ' ' || coalesce(NEW.last_name, '') || ' ' ||
coalesce(NEW.company, '') || ' ' || coalesce(NEW.email, '') || ' ' ||
coalesce(array_to_string(NEW.tags, ' '), ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_contacts_search BEFORE INSERT OR UPDATE ON contacts
FOR EACH ROW EXECUTE FUNCTION update_contacts_search();
CREATE OR REPLACE FUNCTION update_meetings_search() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('pg_catalog.english',
coalesce(NEW.title, '') || ' ' || coalesce(NEW.agenda, '') || ' ' ||
coalesce(NEW.notes_body, '') || ' ' ||
coalesce(array_to_string(NEW.tags, ' '), ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_meetings_search BEFORE INSERT OR UPDATE ON meetings
FOR EACH ROW EXECUTE FUNCTION update_meetings_search();
CREATE OR REPLACE FUNCTION update_decisions_search() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('pg_catalog.english',
coalesce(NEW.title, '') || ' ' || coalesce(NEW.rationale, '') || ' ' ||
coalesce(array_to_string(NEW.tags, ' '), ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_decisions_search BEFORE INSERT OR UPDATE ON decisions
FOR EACH ROW EXECUTE FUNCTION update_decisions_search();
CREATE OR REPLACE FUNCTION update_lists_search() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('pg_catalog.english',
coalesce(NEW.name, '') || ' ' || coalesce(NEW.description, '') || ' ' ||
coalesce(array_to_string(NEW.tags, ' '), ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_lists_search BEFORE INSERT OR UPDATE ON lists
FOR EACH ROW EXECUTE FUNCTION update_lists_search();
CREATE OR REPLACE FUNCTION update_links_search() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('pg_catalog.english',
coalesce(NEW.label, '') || ' ' || coalesce(NEW.url, '') || ' ' ||
coalesce(NEW.description, '') || ' ' ||
coalesce(array_to_string(NEW.tags, ' '), ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_links_search BEFORE INSERT OR UPDATE ON links
FOR EACH ROW EXECUTE FUNCTION update_links_search();
CREATE OR REPLACE FUNCTION update_files_search() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('pg_catalog.english',
coalesce(NEW.original_filename, '') || ' ' || coalesce(NEW.description, '') || ' ' ||
coalesce(array_to_string(NEW.tags, ' '), ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_files_search BEFORE INSERT OR UPDATE ON files
FOR EACH ROW EXECUTE FUNCTION update_files_search();
CREATE OR REPLACE FUNCTION update_weblinks_search() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('pg_catalog.english',
coalesce(NEW.label, '') || ' ' || coalesce(NEW.url, '') || ' ' ||
coalesce(NEW.description, '') || ' ' ||
coalesce(array_to_string(NEW.tags, ' '), ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_weblinks_search BEFORE INSERT OR UPDATE ON weblinks
FOR EACH ROW EXECUTE FUNCTION update_weblinks_search();
CREATE OR REPLACE FUNCTION update_processes_search() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('pg_catalog.english',
coalesce(NEW.name, '') || ' ' || coalesce(NEW.description, '') || ' ' ||
coalesce(array_to_string(NEW.tags, ' '), ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_processes_search BEFORE INSERT OR UPDATE ON processes
FOR EACH ROW EXECUTE FUNCTION update_processes_search();
CREATE OR REPLACE FUNCTION update_appointments_search() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('pg_catalog.english',
coalesce(NEW.title, '') || ' ' || coalesce(NEW.description, '') || ' ' ||
coalesce(NEW.location, '') || ' ' ||
coalesce(array_to_string(NEW.tags, ' '), ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_appointments_search BEFORE INSERT OR UPDATE ON appointments
FOR EACH ROW EXECUTE FUNCTION update_appointments_search();
CREATE OR REPLACE FUNCTION update_releases_search() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('pg_catalog.english',
coalesce(NEW.name, '') || ' ' || coalesce(NEW.description, '') || ' ' ||
coalesce(NEW.version_label, '') || ' ' ||
coalesce(array_to_string(NEW.tags, ' '), ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_releases_search BEFORE INSERT OR UPDATE ON releases
FOR EACH ROW EXECUTE FUNCTION update_releases_search();
-- =============================================================================
-- SEED DATA: Context Types
-- =============================================================================
INSERT INTO context_types (value, label, is_system, sort_order) VALUES
('deep_work', 'Deep Work', true, 10),
('quick', 'Quick', true, 20),
('waiting', 'Waiting', true, 30),
('someday', 'Someday', true, 40),
('meeting', 'Meeting', true, 50),
('errand', 'Errand', true, 60);

241
main.py Normal file
View File

@@ -0,0 +1,241 @@
"""
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,
search as search_router,
admin as admin_router,
lists as lists_router,
files as files_router,
meetings as meetings_router,
decisions as decisions_router,
weblinks as weblinks_router,
appointments as appointments_router,
time_tracking as time_tracking_router,
processes as processes_router,
calendar as calendar_router,
time_budgets as time_budgets_router,
eisenhower as eisenhower_router,
history as history_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 ----
from starlette.types import ASGIApp, Receive, Scope, Send
class RequestContextMiddleware:
"""Pure ASGI middleware - avoids BaseHTTPMiddleware's TaskGroup issues with asyncpg."""
def __init__(self, app: ASGIApp):
self.app = app
self.environment = os.getenv("ENVIRONMENT", "production")
async def __call__(self, scope: Scope, receive: Receive, send: Send):
if scope["type"] == "http":
scope.setdefault("state", {})["environment"] = self.environment
await self.app(scope, receive, send)
app.add_middleware(RequestContextMiddleware)
# ---- 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)
# Overdue projects (target_date in the past)
result = await db.execute(text("""
SELECT p.id, p.name, p.priority, p.target_date, p.status,
d.name as domain_name, d.color as domain_color,
count(t.id) FILTER (WHERE t.is_deleted = false) as task_count,
count(t.id) FILTER (WHERE t.is_deleted = false AND t.status = 'done') as done_count
FROM projects p
LEFT JOIN domains d ON p.domain_id = d.id
LEFT JOIN tasks t ON t.project_id = p.id
WHERE p.is_deleted = false AND p.status IN ('active', 'on_hold')
AND p.target_date < CURRENT_DATE
GROUP BY p.id, d.name, d.color
ORDER BY p.target_date ASC
LIMIT 10
"""))
overdue_projects = [dict(r._mapping) for r in result]
# Upcoming project deadlines (next 30 days)
result = await db.execute(text("""
SELECT p.id, p.name, p.priority, p.target_date, p.status,
d.name as domain_name, d.color as domain_color,
count(t.id) FILTER (WHERE t.is_deleted = false) as task_count,
count(t.id) FILTER (WHERE t.is_deleted = false AND t.status = 'done') as done_count
FROM projects p
LEFT JOIN domains d ON p.domain_id = d.id
LEFT JOIN tasks t ON t.project_id = p.id
WHERE p.is_deleted = false AND p.status IN ('active', 'on_hold')
AND p.target_date >= CURRENT_DATE AND p.target_date <= CURRENT_DATE + INTERVAL '30 days'
GROUP BY p.id, d.name, d.color
ORDER BY p.target_date ASC
LIMIT 10
"""))
upcoming_projects = [dict(r._mapping) for r in result]
return templates.TemplateResponse("dashboard.html", {
"request": request,
"sidebar": sidebar,
"focus_items": focus_items,
"overdue_tasks": overdue_tasks,
"upcoming_tasks": upcoming_tasks,
"overdue_projects": overdue_projects,
"upcoming_projects": upcoming_projects,
"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)
app.include_router(search_router.router)
app.include_router(admin_router.router)
app.include_router(lists_router.router)
app.include_router(files_router.router)
app.include_router(meetings_router.router)
app.include_router(decisions_router.router)
app.include_router(weblinks_router.router)
app.include_router(appointments_router.router)
app.include_router(time_tracking_router.router)
app.include_router(processes_router.router)
app.include_router(calendar_router.router)
app.include_router(time_budgets_router.router)
app.include_router(eisenhower_router.router)
app.include_router(history_router.router)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,349 @@
**Life OS**
Server & Infrastructure Configuration
**1. Server Overview**
-----------------------------------------------------------------------
**Property** **Value**
---------------------- ------------------------------------------------
Provider Hetzner Cloud
Server Name defiant-01
Public IP 46.225.166.142
IPv6 2a01:4f8:1c1f:9d94::1
OS Ubuntu 24.04.4 LTS (Noble Numbat)
Kernel Linux 6.8.0-90-generic x86_64
CPU Cores 12
RAM 22 GB
Disk 451 GB total / \~395 GB available
Swap 8 GB
-----------------------------------------------------------------------
**1.1 Installed Software**
-----------------------------------------------------------------------
**Software** **Version** **Notes**
------------------ --------------- ------------------------------------
Ubuntu 24.04.4 LTS Base OS
Python 3.12.3 Host-level, available system-wide
Nginx 1.24.0 Host-level reverse proxy, not
containerized
Docker Active Managing all application containers
PostgreSQL (host) Not installed Postgres runs in Docker containers
only
-----------------------------------------------------------------------
**1.2 Hetzner Cloud Firewall**
Firewall name: firewall-1
------------------------------------------------------------------------------
**Protocol** **Port** **Source** **Purpose**
-------------- ---------- ------------------ ---------------------------------
TCP 22 0.0.0.0/0 SSH access
TCP 80 0.0.0.0/0 HTTP (redirects to HTTPS via
Nginx)
TCP 443 0.0.0.0/0 HTTPS
TCP 8443 0.0.0.0/0 Kasm Workspaces (internal, set
during setup)
------------------------------------------------------------------------------
*Note: UFW is inactive on the host. Docker manages iptables rules
directly for container port exposure. No host-level firewall changes are
needed for new services - Nginx proxies all traffic on 80/443.*
**2. DNS Records**
Domain registrar / DNS provider: managed by Michael
Primary domain: invixiom.com
**2.1 Active DNS Records**
-----------------------------------------------------------------------------------------------
**Subdomain** **Type** **Value** **Purpose** **Status**
----------------------------- ---------- ---------------- ---------------------- --------------
**kasm.invixiom.com** A 46.225.166.142 Kasm Workspaces **ACTIVE**
virtual desktop
**files.invixiom.com** A 46.225.166.142 Nextcloud file storage **ACTIVE**
**lifeos.invixiom.com** A 46.225.166.142 Life OS PROD **PENDING**
application
**lifeos-dev.invixiom.com** A 46.225.166.142 Life OS DEV **PENDING**
application
**code.invixiom.com** A 46.225.166.142 Reserved - future use **RESERVED**
-----------------------------------------------------------------------------------------------
*Note: PENDING means DNS record exists but the Nginx config and
application container are not yet deployed. ACTIVE means fully
configured end-to-end.*
**3. Nginx Configuration**
Nginx runs directly on the host (not in Docker). Config files located at
/etc/nginx/sites-available/. The active config is invixiom (symlinked to
sites-enabled).
**3.1 SSL Certificates**
----------------------------------------------------------------------------------------------------------
**Certificate** **Path** **Covers** **Provider**
----------------- ------------------------------------------------------- ----------------- --------------
Primary cert /etc/letsencrypt/live/kasm.invixiom.com/fullchain.pem All active Let\'s Encrypt
subdomains
(wildcard or SAN)
Primary key /etc/letsencrypt/live/kasm.invixiom.com/privkey.pem All active Let\'s Encrypt
subdomains
Legacy cert /etc/nginx/ssl/invixiom.crt Old config only Self-signed or
(kasm manual
site-available)
----------------------------------------------------------------------------------------------------------
*Note: The Let\'s Encrypt cert path uses kasm.invixiom.com as the
primary name. When lifeos.invixiom.com and lifeos-dev.invixiom.com are
added to Nginx, the cert will need to be renewed/expanded to cover the
new subdomains.*
**3.2 Configured Virtual Hosts**
-------------------------------------------------------------------------------------
**Server Name** **Listens **Proxies To** **Notes**
On**
------------------------- ----------- ------------------------ ----------------------
kasm.invixiom.com 443 ssl https://127.0.0.1:8443 WebSocket support,
ssl_verify off, 30min
timeout
files.invixiom.com 443 ssl http://127.0.0.1:8080 Nextcloud container
lifeos-api.invixiom.com 443 ssl http://127.0.0.1:8000 LEGACY - maps to stub
container, to be
replaced
code.invixiom.com 443 ssl http://127.0.0.1:8081 Nothing running on
8081 yet
lifeos.invixiom.com 443 ssl http://127.0.0.1:8002 TO BE ADDED - Life OS
PROD
lifeos-dev.invixiom.com 443 ssl http://127.0.0.1:8003 TO BE ADDED - Life OS
DEV
-------------------------------------------------------------------------------------
**4. Docker Containers**
**4.1 Currently Running Containers**
-------------------------------------------------------------------------------------------------------
**Container Name** **Image** **Ports** **Purpose** **Touch?**
------------------------ --------------------------- ------------- ---------------------- -------------
fastapi stack-fastapi 8000-\>8000 Stub health check **REPLACE**
only - to be replaced
by Life OS PROD
nextcloud nextcloud:27-apache 8080-\>80 Nextcloud file storage **DO NOT
(files.invixiom.com) TOUCH**
redis redis:7-alpine internal Task queue for **DO NOT
existing stack TOUCH**
kasm_proxy kasmweb/proxy:1.18.0 8443-\>8443 Kasm entry point **DO NOT
(kasm.invixiom.com) TOUCH**
kasm_rdp_https_gateway kasmweb/rdp-https-gateway internal Kasm RDP gateway **DO NOT
TOUCH**
kasm_rdp_gateway kasmweb/rdp-gateway 3389-\>3389 Kasm RDP **DO NOT
TOUCH**
kasm_agent kasmweb/agent:1.18.0 internal Kasm agent **DO NOT
TOUCH**
kasm_guac kasmweb/kasm-guac internal Kasm Guacamole **DO NOT
TOUCH**
kasm_api kasmweb/api:1.18.0 internal Kasm API **DO NOT
TOUCH**
kasm_manager kasmweb/manager:1.18.0 internal Kasm manager **DO NOT
TOUCH**
kasm_db kasmweb/postgres:1.18.0 internal Kasm dedicated **DO NOT
Postgres TOUCH**
celery stack-celery internal Celery worker for **DO NOT
existing stack TOUCH**
postgres postgres:16-alpine internal Postgres for existing **DO NOT
stack TOUCH**
-------------------------------------------------------------------------------------------------------
**4.2 Planned Life OS Containers**
-------------------------------------------------------------------------------------------
**Container **Image** **Port** **Purpose** **Status**
Name**
--------------- -------------------- ------------- --------------------------- ------------
lifeos-db postgres:16-alpine internal only Dedicated Postgres for Life **ACTIVE**
OS - hosts lifeos_prod and
lifeos_dev databases
lifeos-prod lifeos-app (custom) 8002-\>8002 Life OS PROD application **TO BE
(lifeos.invixiom.com) CREATED**
lifeos-dev lifeos-app (custom) 8003-\>8003 Life OS DEV application **TO BE
(lifeos-dev.invixiom.com) CREATED**
-------------------------------------------------------------------------------------------
**4.3 Port Allocation**
-----------------------------------------------------------------------------
**Port** **Used By** **Direction** **Notes**
---------- ------------------- --------------- ------------------------------
22 SSH External Hetzner firewall open
inbound
80 Nginx External HTTP redirect to HTTPS
inbound
443 Nginx External HTTPS, all subdomains
inbound
3389 kasm_rdp_gateway External Hetzner firewall open
inbound
8000 fastapi (stub) Internal To be repurposed or removed
8080 nextcloud Internal Proxied via files.invixiom.com
8081 code.invixiom.com Internal Reserved, nothing running
8443 kasm_proxy External Kasm, Hetzner firewall open
inbound
8002 lifeos-prod Internal To be created - proxied via
lifeos.invixiom.com
8003 lifeos-dev Internal To be created - proxied via
lifeos-dev.invixiom.com
-----------------------------------------------------------------------------
**5. Docker Networks**
------------------------------------------------------------------------------------
**Network Name** **Driver** **Subnet** **Used By**
---------------------- ----------------- --------------- ---------------------------
bridge bridge 172.17.0.0/16 Default Docker bridge
kasm_default_network bridge 172.19.0.0/16 All Kasm containers
kasm_sidecar_network kasmweb/sidecar 172.20.0.0/16 Kasm sidecar
stack_web bridge 172.18.0.0/16 fastapi, celery, redis,
postgres containers
lifeos_network bridge 172.21.0.0/16 ACTIVE - lifeos-prod,
lifeos-dev, lifeos-db
------------------------------------------------------------------------------------
**6. Application Directories**
All Life OS application files live under /opt/lifeos on the host,
mounted into containers as volumes.
--------------------------------------------------------------------------
**Path** **Purpose** **Status**
----------------------------- --------------------------- ----------------
/opt/lifeos/lifeos-setup.sh Infrastructure setup script **ACTIVE**
/opt/lifeos/prod PROD application files and **ACTIVE**
config
/opt/lifeos/prod/files PROD user uploaded files **ACTIVE**
storage
/opt/lifeos/dev DEV application files and **ACTIVE**
config
/opt/lifeos/dev/files DEV user uploaded files **ACTIVE**
storage
lifeos_db_data (Docker Postgres data persistence **ACTIVE**
volume)
--------------------------------------------------------------------------
**7. Pending Configuration Tasks**
The following items are in sequence order and must be completed to
finish the infrastructure setup:
--------------------------------------------------------------------------------------------
**\#** **Task** **Status** **Notes**
-------- ------------------------------ -------------- -------------------------------------
1 Verify DNS propagation for **COMPLETE** Verified 2026-02-27
lifeos.invixiom.com and
lifeos-dev.invixiom.com
2 Create Docker network: **PENDING**
lifeos_network
3 Create lifeos-db Postgres **COMPLETE** Container: lifeos-db, image:
container postgres:16-alpine
4 Create lifeos_prod and **COMPLETE** lifeos_dev user created with separate
lifeos_dev databases inside password
lifeos-db
5 Create application directory **COMPLETE** /opt/lifeos/prod, /opt/lifeos/dev,
structure on host file storage dirs
6 Migrate existing Supabase **COMPLETE** 3 domains, 10 areas, 18 projects, 73
production data to lifeos_prod tasks, 5 links, 5 daily_focus, 80
capture, 6 context_types. Files table
empty - Supabase Storage paths
obsolete, files start fresh in R1.
7 Build Life OS Docker image **PENDING** FastAPI app, Python 3.12
(Dockerfile)
8 Create docker-compose.yml for **PENDING** PROD and DEV services
Life OS stack
9 Add lifeos.invixiom.com and **PENDING** New server blocks in
lifeos-dev.invixiom.com to /etc/nginx/sites-available/invixiom
Nginx config
10 Expand SSL cert to cover new **PENDING** Add lifeos.invixiom.com and
subdomains (certbot \--expand) lifeos-dev.invixiom.com to cert
11 Remove or retire stub fastapi **PENDING** After Life OS PROD is live
container on port 8000
12 Test end-to-end: HTTPS access **PENDING**
to lifeos.invixiom.com and
lifeos-dev.invixiom.com
--------------------------------------------------------------------------------------------
Life OS Server & Infrastructure Configuration \| Last updated:
2026-02-27

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,57 @@
# Life OS - Conversation Context (Test Infrastructure - Convo Test1)
## What This Is
Life OS is my personal productivity web application, live at https://lifeos-dev.invixiom.com on my self-hosted Hetzner server (defiant-01, 46.225.166.142). Convos 1-4 built 18 routers covering hierarchy, tasks, knowledge, daily workflows, search, admin, meetings, decisions, weblinks, appointments, and time tracking. Convo Test1 built a dynamic, introspection-based automated test suite that discovers routes from the live FastAPI app at runtime -- no hardcoded routes anywhere.
## How to Use the Project Documents
**lifeos-development-status-test1.md** - START HERE. Source of truth for the test infrastructure: what's deployed, how it works, what state it's in, and what to do next.
**lifeos-development-status-convo4.md** - Application source of truth. What's built, routers, templates, deploy patterns, remaining features. The test suite tests THIS application.
**lifeos-architecture.docx** - Full system specification. 50 tables, all subsystems. Reference when adding seed data for new entities.
**lifeos_r1_full_schema.sql** - Intended R1 schema. The test DB is cloned from the live dev DB (not this file), so always verify against: `docker exec lifeos-db psql -U postgres -d lifeos_dev -c "\d table_name"`
**life-os-server-config.docx** - Server infrastructure: containers, ports, Docker networks, Nginx, SSL.
## Current Tech Stack
- Python 3.12 / FastAPI / SQLAlchemy 2.0 async (raw SQL via text(), no ORM models) / asyncpg
- Jinja2 server-rendered templates, vanilla HTML/CSS/JS, no build pipeline
- PostgreSQL 16 in Docker, full-text search via tsvector
- Dark/light theme via CSS custom properties
- Container runs with hot reload (code mounted as volume)
- GitHub repo: mdombaugh/lifeos-dev (main branch)
## Key Patterns (Application)
- BaseRepository handles all CRUD with soft deletes (is_deleted filtering automatic)
- Every route calls get_sidebar_data(db) for the nav tree
- Forms use standard HTML POST with 303 redirect (PRG pattern)
- Templates extend base.html
- Exception: time_entries has no updated_at column, so use direct SQL for deletes instead of BaseRepository.soft_delete()
- Timer state: get_running_task_id() helper in routers/tasks.py queries time_entries WHERE end_at IS NULL
## Key Patterns (Test Suite)
- Tests introspect `app.routes` at import time to discover all paths, methods, Form() fields, and path params
- Dynamic tests auto-parametrize from the route registry -- adding a new router requires zero test file changes for smoke/CRUD coverage
- Business logic tests (timer constraints, soft delete behavior, search safety) are hand-written in test_business_logic.py
- Test DB: `lifeos_test` -- schema cloned from `lifeos_dev` via pg_dump on each deploy
- Per-test isolation: each test runs inside a transaction that rolls back
- Seed data: 15 entity fixtures inserted via raw SQL, composite `all_seeds` fixture
- `PREFIX_TO_SEED` in registry.py maps route prefixes to seed fixture keys for dynamic path resolution
- Form data auto-generated from introspected Form() signatures via form_factory.py
## Deploy Cycle (Application)
Code lives at /opt/lifeos/dev/ on the server. The container mounts this directory and uvicorn --reload picks up changes. No rebuild needed for code changes. Claude creates deploy scripts with heredocs that are uploaded via SCP and run with bash.
## Deploy Cycle (Tests)
```bash
scp deploy-tests.sh root@46.225.166.142:/opt/lifeos/dev/
ssh root@46.225.166.142
cd /opt/lifeos/dev && bash deploy-tests.sh
docker exec lifeos-dev bash /app/tests/run_tests.sh report # Verify introspection
docker exec lifeos-dev bash /app/tests/run_tests.sh # Full suite
```
## What I Need Help With
[State your current task here]

View File

@@ -0,0 +1,42 @@
# Life OS - Conversation Context (Convo 4)
## What This Is
Life OS is my personal productivity web application, live at https://lifeos-dev.invixiom.com on my self-hosted Hetzner server (defiant-01, 46.225.166.142). Convo 1 built the foundation (9 entity routers). Convo 2 added 7 more routers (search, trash, lists, files, meetings, decisions, weblinks). Convo 3 began Tier 3 (Time & Process subsystems), completing Appointments CRUD and Time Tracking with topbar timer pill. Convo 4 completed the time tracking UX by adding timer play/stop buttons to task list rows and task detail pages.
## How to Use the Project Documents
**lifeos-development-status-convo4.md** - START HERE. Source of truth for what's built, what's remaining, exact deploy state, file locations, and patterns to follow. Read this before doing any work.
**lifeos-architecture.docx** - Full system specification. 50 tables, all subsystems, UI patterns, component library, frontend design tokens, search architecture, time management logic, AI/MCP design (Phase 2). Reference when building new features.
**lifeos_r1_full_schema.sql** - The complete intended R1 schema including all tables, indexes, triggers. Verify against the live database when in doubt: `docker exec lifeos-db psql -U postgres -d lifeos_dev -c "\d table_name"`
**life-os-server-config.docx** - Server infrastructure: containers, ports, Docker networks, Nginx, SSL. Key detail: lifeos Nginx blocks use cert path `kasm.invixiom.com-0001` (not `kasm.invixiom.com`).
**Previous conversation docs** - Convo 3 and earlier docs are superseded by Convo 4 docs but provide historical context if needed.
## Current Tech Stack
- Python 3.12 / FastAPI / SQLAlchemy 2.0 async (raw SQL via text(), no ORM models) / asyncpg
- Jinja2 server-rendered templates, vanilla HTML/CSS/JS, no build pipeline
- PostgreSQL 16 in Docker, full-text search via tsvector
- Dark/light theme via CSS custom properties
- Container runs with hot reload (code mounted as volume)
- GitHub repo: mdombaugh/lifeos-dev (main branch)
## Key Patterns
- BaseRepository handles all CRUD with soft deletes (is_deleted filtering automatic)
- Every route calls get_sidebar_data(db) for the nav tree
- Forms use standard HTML POST with 303 redirect (PRG pattern)
- Templates extend base.html
- New routers: create file in routers/, add import + include_router in main.py, add nav link in base.html sidebar, create list/form/detail templates
- Search: add entity config to SEARCH_ENTITIES in routers/search.py
- Trash: add entity config to TRASH_ENTITIES in routers/admin.py
- Nullable fields for BaseRepository.update(): add to nullable_fields set in core/base_repository.py
- Exception: time_entries has no updated_at column, so use direct SQL for deletes instead of BaseRepository.soft_delete()
- Timer state: get_running_task_id() helper in routers/tasks.py queries time_entries WHERE end_at IS NULL
## Deploy Cycle
Code lives at /opt/lifeos/dev/ on the server. The container mounts this directory and uvicorn --reload picks up changes. No rebuild needed for code changes. Claude creates deploy scripts with heredocs that are uploaded via SCP and run with bash. GitHub repo is mdombaugh/lifeos-dev. Push with PAT (personal access token) as password.
## What I Need Help With
[State your current task here]

View File

@@ -0,0 +1,44 @@
# Life OS - Database Backup & Restore
## Quick Backup
```bash
docker exec lifeos-db pg_dump -U postgres -d lifeos_dev -Fc -f /tmp/lifeos_dev_backup.dump
docker cp lifeos-db:/tmp/lifeos_dev_backup.dump /opt/lifeos/backups/lifeos_dev_$(date +%Y%m%d_%H%M%S).dump
```
## Quick Restore
```bash
# Drop and recreate the database, then restore
docker exec lifeos-db psql -U postgres -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'lifeos_dev' AND pid <> pg_backend_pid();"
docker exec lifeos-db psql -U postgres -c "DROP DATABASE lifeos_dev;"
docker exec lifeos-db psql -U postgres -c "CREATE DATABASE lifeos_dev;"
docker cp /opt/lifeos/backups/FILENAME.dump lifeos-db:/tmp/restore.dump
docker exec lifeos-db pg_restore -U postgres -d lifeos_dev /tmp/restore.dump
docker restart lifeos-dev
```
Replace `FILENAME.dump` with the actual backup filename.
## First-Time Setup
Create the backups directory:
```bash
mkdir -p /opt/lifeos/backups
```
## List Available Backups
```bash
ls -lh /opt/lifeos/backups/
```
## Notes
- `-Fc` = custom format (compressed, supports selective restore)
- Backup includes schema + data + indexes + triggers + search vectors
- Restore terminates active connections first, then drops/recreates the DB
- Restart the app container after restore so connection pool reconnects
- lifeos_prod is untouched by these commands (only lifeos_dev)

View File

@@ -0,0 +1,329 @@
# Life OS - Development Status & Continuation Guide (Convo 4)
**Last Updated:** 2026-02-28
**Current State:** Phase 1 - Tier 3 in progress (2 of 6 features built, time tracking UX complete)
**GitHub:** mdombaugh/lifeos-dev (main branch)
---
## 1. What Was Built in This Conversation
### Timer Buttons on Task UI (DEPLOYED)
- Play/stop button on each non-completed task row in tasks.html (between checkbox and priority dot)
- Play/stop button in task_detail.html header action bar (before Edit/Complete buttons)
- Running task row gets green left border highlight via `.timer-active` CSS class
- `get_running_task_id()` helper in routers/tasks.py queries `time_entries WHERE end_at IS NULL`
- Both list_tasks and task_detail routes pass `running_task_id` to template context
- Buttons POST to existing `/time/start` and `/time/stop` endpoints, redirect back via referer
- Only shown on non-completed, non-cancelled tasks
- ~60 lines of CSS appended to style.css (timer-btn, timer-btn-play, timer-btn-stop, timer-active, timer-detail-btn)
- Deployed via heredoc shell script (deploy-timer-buttons.sh)
This completes the Time Tracking feature. The full time tracking system is now:
- Start/stop timer per task from task list rows, task detail page, or time log page
- Topbar timer pill with green pulsing dot, task name link, live elapsed counter, stop button
- Auto-stop of running timer when starting a new one
- Manual time entry support
- Time log at /time with daily summaries, date-grouped entries, day filter
- Soft delete via direct SQL (time_entries lacks updated_at column)
### What Was NOT Built (deferred to Convo 5)
- **Processes / process_runs** - Most complex Tier 3 feature. 4 tables. Deferred due to usage limits.
- **Calendar view** - Unified read-only view
- **Time budgets** - Simple CRUD
- **Eisenhower matrix** - Derived view
---
## 2. Complete Application Inventory
### 2.1 Infrastructure (unchanged)
| Component | Status | Details |
|-----------|--------|---------|
| Server | LIVE | defiant-01, Hetzner, 46.225.166.142, Ubuntu 24.04 |
| Docker network | LIVE | `lifeos_network` (172.21.0.0/16) |
| PostgreSQL | LIVE | Container `lifeos-db`, postgres:16-alpine, volume `lifeos_db_data` |
| Databases | LIVE | `lifeos_prod` (R0 data, untouched), `lifeos_dev` (R1 schema + migrated data) |
| Application | LIVE | Container `lifeos-dev`, port 8003, image `lifeos-app` |
| Nginx | LIVE | lifeos-dev.invixiom.com -> localhost:8003 |
| SSL | LIVE | Let's Encrypt cert at `/etc/letsencrypt/live/kasm.invixiom.com-0001/` |
| GitHub | PUSHED | Convo 3 changes pushed. Convo 4 changes need push (see section 4.2). |
### 2.2 Core Modules
- `core/database.py` - Async engine, session factory, get_db dependency
- `core/base_repository.py` - Generic CRUD: list, get, create, update, soft_delete, restore, permanent_delete, bulk_soft_delete, reorder, count, list_deleted. Has `nullable_fields` set for update() null handling.
- `core/sidebar.py` - Domain > area > project nav tree, capture/focus badge counts
- `main.py` - FastAPI app, dashboard, health check, 18 router includes
### 2.3 Routers (18 total)
| Router | Prefix | Templates | Status |
|--------|--------|-----------|--------|
| domains | /domains | domains, domain_form | Convo 1 |
| areas | /areas | areas, area_form | Convo 1 |
| projects | /projects | projects, project_form, project_detail | Convo 1 |
| tasks | /tasks | tasks, task_form, task_detail | Convo 1, **updated Convo 4** |
| notes | /notes | notes, note_form, note_detail | Convo 1 |
| links | /links | links, link_form | Convo 1 |
| focus | /focus | focus | Convo 1 |
| capture | /capture | capture | Convo 1 |
| contacts | /contacts | contacts, contact_form, contact_detail | Convo 1 |
| search | /search | search | Convo 2 |
| admin | /admin/trash | trash | Convo 2 |
| lists | /lists | lists, list_form, list_detail | Convo 2 |
| files | /files | files, file_upload, file_preview | Convo 2 |
| meetings | /meetings | meetings, meeting_form, meeting_detail | Convo 2 |
| decisions | /decisions | decisions, decision_form, decision_detail | Convo 2 |
| weblinks | /weblinks | weblinks, weblink_form, weblink_folder_form | Convo 2 |
| appointments | /appointments | appointments, appointment_form, appointment_detail | Convo 3 |
| time_tracking | /time | time_entries | Convo 3 |
### 2.4 Templates (42 total, unchanged from Convo 3)
base.html, dashboard.html, search.html, trash.html,
tasks.html, task_form.html, task_detail.html,
projects.html, project_form.html, project_detail.html,
domains.html, domain_form.html,
areas.html, area_form.html,
notes.html, note_form.html, note_detail.html,
links.html, link_form.html,
focus.html, capture.html,
contacts.html, contact_form.html, contact_detail.html,
lists.html, list_form.html, list_detail.html,
files.html, file_upload.html, file_preview.html,
meetings.html, meeting_form.html, meeting_detail.html,
decisions.html, decision_form.html, decision_detail.html,
weblinks.html, weblink_form.html, weblink_folder_form.html,
appointments.html, appointment_form.html, appointment_detail.html,
time_entries.html
### 2.5 Static Assets
- `style.css` - ~1040 lines (timer button CSS appended in Convo 4)
- `app.js` - ~190 lines (timer pill polling from Convo 3, unchanged in Convo 4)
---
## 3. How the Container Runs
```bash
docker run -d \
--name lifeos-dev \
--network lifeos_network \
--restart unless-stopped \
--env-file .env \
-p 8003:8003 \
-v /opt/lifeos/dev/files:/opt/lifeos/files/dev \
-v /opt/lifeos/dev:/app \
lifeos-app \
uvicorn main:app --host 0.0.0.0 --port 8003 --workers 1 --reload
```
**Environment (.env):**
```
DATABASE_URL=postgresql+asyncpg://postgres:UCTOQDZiUhN8U@lifeos-db:5432/lifeos_dev
FILE_STORAGE_PATH=/opt/lifeos/files/dev
ENVIRONMENT=development
```
Deploy: edit files in `/opt/lifeos/dev/`, hot reload picks them up.
Restart: `docker restart lifeos-dev`
Logs: `docker logs lifeos-dev --tail 30`
---
## 4. Known Issues
### 4.1 Immediate
1. **Not yet tested by user** - Timer buttons deployed but user testing still pending. May have bugs.
2. **Convo 4 changes not pushed to GitHub** - Run: `cd /opt/lifeos/dev && git add . && git commit -m "Timer buttons on task rows and detail" && git push origin main`
### 4.2 Technical Debt
1. **time_entries missing `updated_at`** - Table lacks this column so BaseRepository methods that set updated_at will fail. Direct SQL used for soft_delete. If adding time_entries to TRASH_ENTITIES, restore will also need direct SQL.
2. **R1 schema file mismatch** - lifeos_schema_r1.sql in project doesn't reflect actual DB. Query DB directly to verify.
3. **No CSRF protection** - Single-user system, low risk.
4. **No pagination** - All list views load all rows. Fine at current scale.
5. **Font loading** - Google Fonts @import is render-blocking.
---
## 5. What's NOT Built Yet
### Tier 3 Remaining (4 features)
1. **Processes / process_runs** - Most complex Tier 3 feature. 4 tables: processes, process_steps, process_runs, process_run_steps. Template CRUD, run instantiation (copies steps as immutable snapshot), step completion tracking, task generation modes (all_at_once vs step_by_step). START HERE in Convo 5.
2. **Calendar view** - Unified `/calendar` page showing appointments (start_at) + meetings (meeting_date) + tasks (due_date). No new tables, read-only derived view. Filter by date range, domain, type.
3. **Time budgets** - Simple CRUD: domain_id + weekly_hours + effective_from. Used for overcommitment warnings on dashboard.
4. **Eisenhower matrix** - Derived 2x2 grid from task priority + due_date. Quadrants: Important+Urgent (priority 1-2, due <=7d), Important+Not Urgent (priority 1-2, due >7d), Not Important+Urgent (priority 3-4, due <=7d), Not Important+Not Urgent (priority 3-4, due >7d or null). Clickable to filter task list.
### Tier 4 - Advanced Features
- Releases / milestones
- Dependencies (DAG, cycle detection, status cascade)
- Task templates (instantiation with subtask generation)
- Note wiki-linking ([[ syntax)
- Note folders
- Bulk actions (multi-select, bulk complete/move/delete)
- CSV export
- Drag-to-reorder (SortableJS)
- Reminders
- Weekly review process template
- Dashboard metrics (weekly/monthly completion stats)
### UX Polish
- Breadcrumb navigation (partially done, inconsistent)
- Overdue visual treatment (red left border on task rows)
- Empty states with illustrations (basic emoji states exist)
- Skeleton loading screens
- Toast notification system
- Confirmation dialogs (basic confirm() exists, no modal)
- Mobile bottom tab bar
- Mobile responsive improvements
---
## 6. File Locations on Server
```
/opt/lifeos/
dev/ # DEV application (mounted as /app in container)
main.py # 18 router includes
core/
__init__.py
database.py
base_repository.py
sidebar.py
routers/
__init__.py
domains.py, areas.py, projects.py, tasks.py
notes.py, links.py, focus.py, capture.py, contacts.py
search.py, admin.py, lists.py
files.py, meetings.py, decisions.py, weblinks.py
appointments.py
time_tracking.py
templates/
base.html, dashboard.html, search.html, trash.html
tasks.html, task_form.html, task_detail.html
projects.html, project_form.html, project_detail.html
domains.html, domain_form.html
areas.html, area_form.html
notes.html, note_form.html, note_detail.html
links.html, link_form.html
focus.html, capture.html
contacts.html, contact_form.html, contact_detail.html
lists.html, list_form.html, list_detail.html
files.html, file_upload.html, file_preview.html
meetings.html, meeting_form.html, meeting_detail.html
decisions.html, decision_form.html, decision_detail.html
weblinks.html, weblink_form.html, weblink_folder_form.html
appointments.html, appointment_form.html, appointment_detail.html
time_entries.html
static/
style.css (~1040 lines)
app.js (~190 lines)
Dockerfile
requirements.txt
.env
backups/ # Database backups
```
---
## 7. How to Continue Development
### Recommended build order for Convo 5:
1. **Processes / process_runs** (most complex remaining feature - do first with full usage window)
2. **Calendar view** (combines appointments + meetings + tasks)
3. **Time budgets** (simple CRUD)
4. **Eisenhower matrix** (derived view, quick win)
### Adding a new entity router (pattern):
1. Create `routers/entity_name.py` following existing router patterns
2. Add import + `app.include_router()` in `main.py`
3. Create templates: list, form, detail (all extend base.html)
4. Add nav link in `templates/base.html` sidebar section
5. Add to `SEARCH_ENTITIES` in `routers/search.py` (if searchable)
6. Add to `TRASH_ENTITIES` in `routers/admin.py` (if soft-deletable)
7. Add any new nullable fields to `nullable_fields` in `core/base_repository.py`
8. Use `BaseRepository("table_name", db)` for all CRUD
9. Always call `get_sidebar_data(db)` and pass to template context
### Deploy cycle:
```bash
# Files are created locally by Claude, packaged as a deploy script with heredocs
# Upload to server, run the script
scp deploy-script.sh root@46.225.166.142:/opt/lifeos/dev/
ssh root@46.225.166.142
cd /opt/lifeos/dev && bash deploy-script.sh
# Commit
git add . && git commit -m "description" && git push origin main
```
### Database backup:
```bash
mkdir -p /opt/lifeos/backups
docker exec lifeos-db pg_dump -U postgres -d lifeos_dev -Fc -f /tmp/lifeos_dev_backup.dump
docker cp lifeos-db:/tmp/lifeos_dev_backup.dump /opt/lifeos/backups/lifeos_dev_$(date +%Y%m%d_%H%M%S).dump
```
### Key code patterns:
- Every route: `sidebar = await get_sidebar_data(db)`
- Forms POST to /create or /{id}/edit, redirect 303
- Filters: query params, auto-submit via JS onchange
- Detail views: breadcrumb nav at top
- Toggle/complete: inline form with checkbox onchange
- Junction tables: raw SQL INSERT with ON CONFLICT DO NOTHING
- File upload: multipart form, save to FILE_STORAGE_PATH, record in files table
- Timer: POST /time/start with task_id, POST /time/stop, GET /time/running (JSON for topbar pill)
- Timer buttons: get_running_task_id() helper in tasks.py, play/stop inline forms on task rows
---
## 8. Tier 3 Architecture Reference
### Processes / Process Runs (BUILD NEXT)
**Tables:** processes, process_steps, process_runs, process_run_steps
**Flow:**
1. Create a process template (processes) with ordered steps (process_steps)
2. Instantiate a run (process_runs) - copies all process_steps to process_run_steps as immutable snapshots
3. Steps in a run can be completed, which records completed_by_id and completed_at
4. Task generation modes: `all_at_once` creates all tasks when run starts, `step_by_step` creates next task only when current step completes
5. Template changes after run creation do NOT affect active runs (snapshot pattern)
**Schema notes:**
- processes: id, name, description, process_type (workflow|checklist), category, status, tags, search_vector
- process_steps: id, process_id, title, instructions, expected_output, estimated_days, context, sort_order
- process_runs: id, process_id, title, status, process_type (copied from template), task_generation, project_id, contact_id, started_at, completed_at
- process_run_steps: id, run_id, title, instructions (immutable), status, completed_by_id, completed_at, notes, sort_order
### Calendar View
- Unified read-only page at `/calendar`
- Show appointments (start_at), meetings (meeting_date + start_at), tasks (due_date)
- Filter by date range, domain, type
- No new tables needed
### Time Budgets
- Simple CRUD: domain_id + weekly_hours + effective_from
- Dashboard warning when domain time_entries exceed budget
### Eisenhower Matrix
- Derived from task priority (1-4) + due_date
- Quadrants: Important+Urgent, Important+Not Urgent, Not Important+Urgent, Not Important+Not Urgent
- Priority 1-2 = Important, Priority 3-4 = Not Important
- Due <= 7 days or overdue = Urgent, Due > 7 days or no date = Not Urgent
- Rendered as 2x2 grid, clicking quadrant filters to task list
---
## 9. Production Deployment (Not Yet Done)
When ready to go to PROD:
1. Apply R1 schema to lifeos_prod
2. Run data migration on lifeos_prod
3. Build and start lifeos-prod container on port 8002
4. Nginx already has lifeos.invixiom.com block pointing to 8002
5. SSL cert already covers lifeos.invixiom.com
6. Set ENVIRONMENT=production in prod .env
7. Set up daily backup cron job

View File

@@ -0,0 +1,261 @@
# Life OS - Development Status & Continuation Guide (Test Infrastructure - Convo Test1)
**Last Updated:** 2026-03-01
**Current State:** Test suite deployed, introspection verified (121 routes discovered), first test run pending
**GitHub:** mdombaugh/lifeos-dev (main branch)
---
## 1. What Was Built in This Conversation
### Dynamic Introspection-Based Test Suite (DEPLOYED)
Built and deployed an automated test suite that discovers routes from the live FastAPI app at runtime. Zero hardcoded routes. When a new router is added, smoke and CRUD tests auto-expand on next run.
**Architecture (11 files in /opt/lifeos/dev/tests/):**
| File | Purpose | Lines |
|------|---------|-------|
| introspect.py | Route discovery engine: walks app.routes, extracts paths/methods/Form() fields/path params, classifies routes | 357 |
| form_factory.py | Generates valid POST form data from introspected Form() signatures + seed data UUIDs | 195 |
| registry.py | Imports app, runs introspection once, exposes route registry + PREFIX_TO_SEED mapping + resolve_path() | 79 |
| conftest.py | Fixtures only: test DB engine, per-test rollback session, httpx client, 15 seed data fixtures, all_seeds composite | 274 |
| test_smoke_dynamic.py | 3 parametrized functions expanding to ~59 tests: all GETs (no params) return 200, all GETs (with seed ID) return 200, all detail/edit GETs (fake UUID) return 404 | 100 |
| test_crud_dynamic.py | 5 parametrized functions expanding to ~62 tests: all POST create/edit/delete redirect 303, all actions non-500, create-then-verify-in-list | 161 |
| test_business_logic.py | 16 hand-written tests: timer single-run constraint, stop sets end_at, soft delete/restore visibility, search SQL injection, sidebar integrity, focus/capture workflows, edge cases | 212 |
| route_report.py | CLI tool: dumps all discovered routes with classification, form fields, seed mapping coverage | 65 |
| run_tests.sh | Test runner with aliases: smoke, crud, logic, report, fast, full, custom args | 22 |
| __init__.py | Package marker | 0 |
| pytest.ini | Config: asyncio_mode=auto, verbose output, short tracebacks | 7 |
**Key design decisions:**
- `registry.py` separated from `conftest.py` to avoid pytest auto-loading conflicts (test files import from registry, not conftest)
- Form() detection uses `__class__.__name__` check, not `issubclass()`, because FastAPI's `Form` is a function not a class
- Test DB schema cloned from live dev DB via pg_dump (not from stale SQL files)
- Seed data uses raw SQL INSERT matching actual table columns
### Introspection Verification Results
Deploy script step 4 confirmed:
```
Routes discovered: 121
GET (no params): 36
GET (with params): 23
POST create: 13
POST edit: 13
POST delete: 17
POST action: 19
Entity prefixes: 32
```
### What Was NOT Done
- **First test run not yet executed** -- introspection works, tests deployed, but `run_tests.sh` has not been run yet
- **Seed data column mismatches likely** -- seed INSERTs written from architecture docs, not actual table inspection. First run will surface these as SQL errors
- **No test for file upload routes** -- file routes skipped (has_file_upload flag) because they need multipart handling
---
## 2. Test Infrastructure Inventory
### 2.1 Database
| Component | Details |
|-----------|---------|
| Test DB | `lifeos_test` on lifeos-db container |
| Schema source | Cloned from `lifeos_dev` via `pg_dump --schema-only` |
| Tables | 48 (matches dev) |
| Isolation | Per-test transaction rollback (no data persists between tests) |
| Credentials | Same as dev: postgres:UCTOQDZiUhN8U |
### 2.2 How Introspection Works
1. `registry.py` imports `main.app` (sets DATABASE_URL to test DB first)
2. `introspect.py` walks `app.routes`, for each `APIRoute`:
- Extracts path, HTTP methods, endpoint function reference
- Parses `{id}` path parameters via regex
- Inspects endpoint function signature for `Form()` parameters (checks `default.__class__.__name__` for "FieldInfo")
- Extracts query parameters (non-Form, non-Depends, non-Request params)
- Classifies route: list / detail / create_form / edit_form / create / edit / delete / toggle / action / json / page
3. Builds `ROUTE_REGISTRY` dict keyed by kind (get_no_params, post_create, etc.) and by prefix
4. Test files parametrize from this registry at collection time
### 2.3 How Dynamic Tests Work
**Smoke (test_smoke_dynamic.py):**
```python
@pytest.mark.parametrize("path", [r.path for r in GET_NO_PARAMS])
async def test_get_no_params_returns_200(client, path):
r = await client.get(path)
assert r.status_code == 200
```
N discovered GET routes = N smoke tests. No manual updates.
**CRUD (test_crud_dynamic.py):**
- Collects all POST create/edit/delete routes from registry
- Calls `build_form_data(route.form_fields, all_seeds)` to generate valid payloads
- `form_factory.py` resolves FK fields to seed UUIDs, generates values by field name pattern
- Asserts 303 redirect for create/edit/delete, non-500 for actions
**Business Logic (test_business_logic.py):**
- Hand-written, tests behavioral contracts not discoverable via introspection
- Timer: single running constraint, stop sets end_at, /time/running returns JSON
- Soft deletes: deleted task hidden from list, restore reappears
- Search: SQL injection doesn't crash, empty query works, unicode works
- Sidebar: domain appears on every page, project hierarchy renders
- Focus/capture: add to focus, multi-line capture creates multiple items
- Edge cases: invalid UUID, timer without task_id, double delete
### 2.4 Seed Data Fixtures (15 entities)
| Fixture | Table | Dependencies | Key Fields |
|---------|-------|-------------|------------|
| seed_domain | domains | none | id, name, color |
| seed_area | areas | seed_domain | id, domain_id |
| seed_project | projects | seed_domain, seed_area | id, domain_id, area_id |
| seed_task | tasks | seed_domain, seed_project | id, domain_id, project_id, title |
| seed_contact | contacts | none | id, first_name, last_name |
| seed_note | notes | seed_domain | id, domain_id, title |
| seed_meeting | meetings | none | id, title, meeting_date |
| seed_decision | decisions | seed_domain, seed_project | id, title |
| seed_appointment | appointments | seed_domain | id, title, start_at, end_at |
| seed_weblink_folder | weblink_folders | none | id, name |
| seed_list | lists | seed_domain, seed_project | id, name |
| seed_link | links | seed_domain | id, title, url |
| seed_weblink | weblinks | seed_weblink_folder | id, title, url |
| seed_capture | capture | none | id, raw_text |
| seed_focus | daily_focus | seed_task | id, task_id |
### 2.5 PREFIX_TO_SEED Mapping
Maps route prefixes to seed fixture keys so `resolve_path()` can replace `{id}` with real UUIDs:
```
/domains -> domain /contacts -> contact
/areas -> area /meetings -> meeting
/projects -> project /decisions -> decision
/tasks -> task /appointments -> appointment
/notes -> note /weblinks -> weblink
/links -> link /weblinks/folders -> weblink_folder
/lists -> list /focus -> focus
/capture -> capture /time -> task
/files -> None (skipped) /admin/trash -> None (skipped)
```
---
## 3. File Locations on Server
```
/opt/lifeos/dev/
tests/
__init__.py
introspect.py # Route discovery engine
form_factory.py # Form data generation
registry.py # Route registry + PREFIX_TO_SEED + resolve_path
conftest.py # Fixtures (DB, client, seeds)
route_report.py # CLI route dump
test_smoke_dynamic.py # Auto-parametrized GET tests
test_crud_dynamic.py # Auto-parametrized POST tests
test_business_logic.py # Hand-written behavioral tests
run_tests.sh # Test runner
pytest.ini # pytest config
deploy-tests.sh # Deployment script (can re-run to reset)
```
---
## 4. Known Issues & Expected First-Run Failures
### 4.1 Likely Seed Data Mismatches
Seed INSERT statements were written from architecture docs, not from inspecting actual table columns. The first test run will likely produce errors like:
- `column "X" of relation "Y" does not exist` -- seed INSERT has a column the actual table doesn't have
- `null value in column "X" violates not-null constraint` -- seed INSERT is missing a required column
**Fix process:** Run tests, read the SQL errors, adjust the INSERT in conftest.py to match actual columns (query with `\d table_name`), redeploy.
### 4.2 Possible Form Field Discovery Gaps
Some routers may use patterns the introspection engine doesn't handle:
- `Annotated[str, Form()]` style (handled via `__metadata__` check, but untested against live code)
- Form fields with non-standard defaults
- Routes that accept both Form and query params
The route report (`run_tests.sh report`) will show warnings for POST create/edit routes with zero discovered Form fields. Those need investigation.
### 4.3 Route Classification Edge Cases
Some routes may be misclassified:
- Admin trash restore routes (`/admin/trash/restore/{entity}/{id}`) may not match the standard patterns
- Capture routes (`/capture/add`, `/capture/{id}/convert`, `/capture/{id}/dismiss`) use non-standard action patterns
- Focus routes (`/focus/add`, `/focus/{id}/remove`) are action routes, not standard CRUD
These will show up as action route tests (non-500 assertion) rather than typed CRUD tests.
### 4.4 Not Yet Pushed to GitHub
Test files need to be committed: `cd /opt/lifeos/dev && git add . && git commit -m "Dynamic test suite" && git push origin main`
---
## 5. How to Continue (Convo Test2)
### Immediate Next Steps
1. **Run the route report** to verify introspection output:
```bash
docker exec lifeos-dev bash /app/tests/run_tests.sh report
```
2. **Run smoke tests first** (most likely to pass):
```bash
docker exec lifeos-dev bash /app/tests/run_tests.sh smoke
```
3. **Fix seed data failures** by inspecting actual tables and adjusting conftest.py INSERTs
4. **Run CRUD tests** after seeds are fixed:
```bash
docker exec lifeos-dev bash /app/tests/run_tests.sh crud
```
5. **Run business logic tests** last:
```bash
docker exec lifeos-dev bash /app/tests/run_tests.sh logic
```
6. **Run full suite** once individual categories pass:
```bash
docker exec lifeos-dev bash /app/tests/run_tests.sh
```
### When Adding a New Entity Router
1. Add seed fixture to `conftest.py` (INSERT matching actual table columns)
2. Add entry to `PREFIX_TO_SEED` in `registry.py`
3. Run tests -- smoke and CRUD auto-expand to cover new routes
4. Add behavioral tests to `test_business_logic.py` if entity has constraints or state machines
### When Schema Changes
Re-run `deploy-tests.sh` (step 1 drops and recreates lifeos_test from current dev schema).
Or manually:
```bash
docker exec lifeos-db psql -U postgres -c "DROP DATABASE IF EXISTS lifeos_test;"
docker exec lifeos-db psql -U postgres -c "CREATE DATABASE lifeos_test;"
docker exec lifeos-db pg_dump -U postgres -d lifeos_dev --schema-only -f /tmp/s.sql
docker exec lifeos-db psql -U postgres -d lifeos_test -f /tmp/s.sql -q
```
### Test Runner Commands
```bash
docker exec lifeos-dev bash /app/tests/run_tests.sh # Full suite
docker exec lifeos-dev bash /app/tests/run_tests.sh report # Route introspection dump
docker exec lifeos-dev bash /app/tests/run_tests.sh smoke # All GET endpoints
docker exec lifeos-dev bash /app/tests/run_tests.sh crud # All POST create/edit/delete
docker exec lifeos-dev bash /app/tests/run_tests.sh logic # Business logic
docker exec lifeos-dev bash /app/tests/run_tests.sh fast # Smoke, stop on first fail
docker exec lifeos-dev bash /app/tests/run_tests.sh -k "timer" # pytest keyword filter
```
---
## 6. Application Development Remaining (Unchanged from Convo 4)
### Tier 3 Remaining (4 features)
1. **Processes / process_runs** -- Most complex. 4 tables. Template CRUD, run instantiation, step completion, task generation.
2. **Calendar view** -- Unified read-only view of appointments + meetings + tasks.
3. **Time budgets** -- Simple CRUD: domain_id + weekly_hours + effective_from.
4. **Eisenhower matrix** -- Derived 2x2 grid from task priority + due_date.
### Tier 4, UX Polish, Production Deployment
See lifeos-development-status-convo4.md sections 5, 8, 9.

View File

@@ -0,0 +1,263 @@
#!/bin/bash
# =============================================================================
# Life OS Infrastructure Setup Script
# Server: defiant-01 (46.225.166.142) - Ubuntu 24.04 LTS
# Run as: root
# Purpose: Repeatable setup of Life OS DEV and PROD environments on Hetzner VM
# =============================================================================
# USAGE:
# Full run: bash lifeos-setup.sh
# Single section: bash lifeos-setup.sh network
# bash lifeos-setup.sh database
# bash lifeos-setup.sh app
# bash lifeos-setup.sh nginx
# bash lifeos-setup.sh ssl
# =============================================================================
set -e # Exit on any error
# --- Configuration -----------------------------------------------------------
LIFEOS_NETWORK="lifeos_network"
DB_CONTAINER="lifeos-db"
DB_IMAGE="postgres:16-alpine"
DB_PROD="lifeos_prod"
DB_DEV="lifeos_dev"
APP_PROD_CONTAINER="lifeos-prod"
APP_DEV_CONTAINER="lifeos-dev"
APP_PROD_PORT="8002"
APP_DEV_PORT="8003"
DOMAIN_PROD="lifeos.invixiom.com"
DOMAIN_DEV="lifeos-dev.invixiom.com"
CERT_PATH="/etc/letsencrypt/live/kasm.invixiom.com"
LIFEOS_DIR="/opt/lifeos"
# DB passwords - change these before running
DB_PROD_PASSWORD="CHANGE_ME_PROD"
DB_DEV_PASSWORD="CHANGE_ME_DEV"
# -----------------------------------------------------------------------------
section() {
echo ""
echo "=============================================="
echo " $1"
echo "=============================================="
}
# =============================================================================
# SECTION 1: Docker Network
# =============================================================================
setup_network() {
section "SECTION 1: Docker Network"
if docker network ls | grep -q "$LIFEOS_NETWORK"; then
echo "Network $LIFEOS_NETWORK already exists, skipping."
else
docker network create "$LIFEOS_NETWORK"
echo "Created network: $LIFEOS_NETWORK"
fi
docker network ls | grep lifeos
}
# =============================================================================
# SECTION 2: PostgreSQL Container
# =============================================================================
setup_database() {
section "SECTION 2: PostgreSQL Container"
if docker ps -a | grep -q "$DB_CONTAINER"; then
echo "Container $DB_CONTAINER already exists, skipping creation."
else
docker run -d \
--name "$DB_CONTAINER" \
--network "$LIFEOS_NETWORK" \
--restart unless-stopped \
-e POSTGRES_PASSWORD="$DB_PROD_PASSWORD" \
-v lifeos_db_data:/var/lib/postgresql/data \
"$DB_IMAGE"
echo "Created container: $DB_CONTAINER"
echo "Waiting for Postgres to be ready..."
sleep 5
fi
# Create PROD database
docker exec "$DB_CONTAINER" psql -U postgres -tc \
"SELECT 1 FROM pg_database WHERE datname='$DB_PROD'" | grep -q 1 || \
docker exec "$DB_CONTAINER" psql -U postgres \
-c "CREATE DATABASE $DB_PROD;"
# Create DEV database
docker exec "$DB_CONTAINER" psql -U postgres -tc \
"SELECT 1 FROM pg_database WHERE datname='$DB_DEV'" | grep -q 1 || \
docker exec "$DB_CONTAINER" psql -U postgres \
-c "CREATE DATABASE $DB_DEV;"
# Create DEV user with separate password
docker exec "$DB_CONTAINER" psql -U postgres -tc \
"SELECT 1 FROM pg_roles WHERE rolname='lifeos_dev'" | grep -q 1 || \
docker exec "$DB_CONTAINER" psql -U postgres \
-c "CREATE USER lifeos_dev WITH PASSWORD '$DB_DEV_PASSWORD';"
docker exec "$DB_CONTAINER" psql -U postgres \
-c "GRANT ALL PRIVILEGES ON DATABASE $DB_DEV TO lifeos_dev;"
echo "Databases ready:"
docker exec "$DB_CONTAINER" psql -U postgres -c "\l" | grep lifeos
}
# =============================================================================
# SECTION 3: Application Directory Structure
# =============================================================================
setup_app_dirs() {
section "SECTION 3: Application Directory Structure"
mkdir -p "$LIFEOS_DIR/prod"
mkdir -p "$LIFEOS_DIR/dev"
mkdir -p "$LIFEOS_DIR/prod/files"
mkdir -p "$LIFEOS_DIR/dev/files"
echo "Created directory structure:"
ls -la "$LIFEOS_DIR"
}
# =============================================================================
# SECTION 4: Nginx Configuration
# (Run after app containers are up and SSL cert is expanded)
# =============================================================================
setup_nginx() {
section "SECTION 4: Nginx Virtual Hosts"
# Add Life OS PROD and DEV server blocks to existing invixiom config
# We append to the existing file - kasm/files/code blocks remain untouched
if grep -q "$DOMAIN_PROD" /etc/nginx/sites-available/invixiom; then
echo "Nginx config for $DOMAIN_PROD already exists, skipping."
else
cat >> /etc/nginx/sites-available/invixiom << EOF
server {
listen 443 ssl;
server_name $DOMAIN_PROD;
ssl_certificate $CERT_PATH/fullchain.pem;
ssl_certificate_key $CERT_PATH/privkey.pem;
location / {
proxy_pass http://127.0.0.1:$APP_PROD_PORT;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
}
}
server {
listen 443 ssl;
server_name $DOMAIN_DEV;
ssl_certificate $CERT_PATH/fullchain.pem;
ssl_certificate_key $CERT_PATH/privkey.pem;
location / {
proxy_pass http://127.0.0.1:$APP_DEV_PORT;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
}
}
EOF
echo "Added Nginx config for $DOMAIN_PROD and $DOMAIN_DEV"
fi
# Add new domains to the HTTP->HTTPS redirect block
# (manual step - see notes below)
echo ""
echo "NOTE: Also add $DOMAIN_PROD and $DOMAIN_DEV to the server_name line"
echo "in the HTTP redirect block at the top of /etc/nginx/sites-available/invixiom"
# Test and reload
nginx -t && systemctl reload nginx
echo "Nginx reloaded."
}
# =============================================================================
# SECTION 5: SSL Certificate Expansion
# (Expand Let's Encrypt cert to cover new subdomains)
# =============================================================================
setup_ssl() {
section "SECTION 5: SSL Certificate Expansion"
certbot certonly --nginx \
-d kasm.invixiom.com \
-d files.invixiom.com \
-d code.invixiom.com \
-d "$DOMAIN_PROD" \
-d "$DOMAIN_DEV" \
--expand
systemctl reload nginx
echo "SSL cert expanded and Nginx reloaded."
}
# =============================================================================
# MAIN
# =============================================================================
case "${1:-all}" in
network) setup_network ;;
database) setup_database ;;
dirs) setup_app_dirs ;;
nginx) setup_nginx ;;
ssl) setup_ssl ;;
all)
setup_network
setup_database
setup_app_dirs
# nginx and ssl run after app containers are built
echo ""
echo "=============================================="
echo " Sections 1-3 complete."
echo " Next: build Life OS Docker image, then run:"
echo " bash lifeos-setup.sh ssl"
echo " bash lifeos-setup.sh nginx"
echo "=============================================="
;;
*)
echo "Unknown section: $1"
echo "Usage: bash lifeos-setup.sh [network|database|dirs|nginx|ssl|all]"
exit 1
;;
esac
# =============================================================================
# SECTION 6: Data Migration (reference - already completed)
# Documents the steps used to migrate Supabase prod data to lifeos_prod
# =============================================================================
setup_migration_notes() {
section "SECTION 6: Data Migration Notes"
echo "Migration completed 2026-02-27"
echo ""
echo "Steps used:"
echo " 1. Exported data from Supabase using Python supabase client (supabase_export.py)"
echo " 2. Applied schema: docker exec -i lifeos-db psql -U postgres -d lifeos_prod < lifeos_schema_r0.sql"
echo " 3. Imported data: docker exec -i lifeos-db psql -U postgres -d lifeos_prod < lifeos_export.sql"
echo ""
echo "Final row counts:"
docker exec lifeos-db psql -U postgres -d lifeos_prod -c "
SELECT 'domains' as table_name, count(*) FROM domains UNION ALL
SELECT 'areas', count(*) FROM areas UNION ALL
SELECT 'projects', count(*) FROM projects UNION ALL
SELECT 'tasks', count(*) FROM tasks UNION ALL
SELECT 'notes', count(*) FROM notes UNION ALL
SELECT 'links', count(*) FROM links UNION ALL
SELECT 'files', count(*) FROM files UNION ALL
SELECT 'daily_focus', count(*) FROM daily_focus UNION ALL
SELECT 'capture', count(*) FROM capture UNION ALL
SELECT 'context_types', count(*) FROM context_types;
"
echo ""
echo "Note: files table is empty - Supabase Storage paths are obsolete."
echo "File uploads start fresh in Release 1 using local storage."
}

View File

@@ -0,0 +1,667 @@
**Life OS v2**
Data Migration Plan
Old Schema to New Schema Mapping + New Database DDL
-------------------- --------------------------------------------------
Document Version 1.0
Date February 2026
Old System Supabase (PostgreSQL) on Render
New System Self-hosted PostgreSQL on Hetzner VM (defiant-01)
Old Schema Tables 11
New Schema Tables \~50
-------------------- --------------------------------------------------
**1. Migration Overview**
This document defines the data migration from Life OS v1
(Supabase/Render) to Life OS v2 (self-hosted PostgreSQL on Hetzner). The
v1 schema and data remain untouched on Supabase for reference. The v2
schema is a completely separate database with new tables, new
conventions, and expanded capabilities.
**Strategy:** Export v1 data via pg_dump, transform using a Python
migration script, import into the v2 database. V1 remains read-only as a
reference. No shared database, no incremental sync.
**Key principle:** The new schema is NOT an evolution of the old schema.
It is a redesign. Some tables map 1:1 (domains, areas). Others split,
merge, or gain significant new columns. Some v2 tables have no v1
equivalent at all.
**2. Old Schema (R0 State)**
The v1 system has 11 tables. All PKs are UUID via gen_random_uuid().
Timestamps are TIMESTAMPTZ.
--------------- ---------- ---------------------------------------------
**Table** **Row **Purpose**
Est.**
domains 3-5 Top-level life categories (Work, Personal,
Sintri)
areas 5-10 Optional grouping within a domain
projects 10-20 Unit of work within domain/area
tasks 50-200 Atomic actions with priority, status, context
notes 10-50 Markdown documents attached to project/domain
links 10-30 Named URL references
files 5-20 Binary files in Supabase Storage with
metadata
daily_focus 30-100 Date-scoped task commitment list
capture 10-50 Raw text capture queue
context_types 6 GTD execution mode lookup (deep_work, quick,
etc.)
reminders 0 Schema exists but no UI or delivery built
--------------- ---------- ---------------------------------------------
**3. Table-by-Table Migration Mapping**
Each v1 table is mapped to its v2 equivalent(s) with column-level
transformations noted. Universal columns added to all v2 tables:
updated_at, is_active (BOOLEAN DEFAULT true), sort_order (INT DEFAULT
0).
**3.1 domains -\> domains**
**Mapping:** Direct 1:1. Preserve UUIDs.
----------------- ----------------- ------------ ---------------------------
**v1 Column** **v2 Column** **Action** **Notes**
id id Copy Preserve UUID, all FKs
depend on it
name name Copy
color color Copy
created_at created_at Copy
(none) updated_at Generate Set to created_at for
initial import
(none) is_active Default true
(none) sort_order Generate Assign sequential 10, 20,
30\...
(none) description Default NULL - new optional field
(none) icon Default NULL - new optional field
----------------- ----------------- ------------ ---------------------------
**3.2 areas -\> areas**
**Mapping:** Direct 1:1. Preserve UUIDs.
----------------- ----------------- ------------ ---------------------------
**v1 Column** **v2 Column** **Action** **Notes**
id id Copy Preserve UUID
domain_id domain_id Copy FK preserved
name name Copy
description description Copy
created_at created_at Copy
(none) updated_at Generate Set to created_at
(none) is_active Default true
(none) sort_order Generate Sequential per domain
(none) icon Default NULL
(none) color Default NULL - inherit from domain
or set later
----------------- ----------------- ------------ ---------------------------
**3.3 projects -\> projects**
**Mapping:** Direct 1:1 with new columns. Preserve UUIDs.
----------------- ----------------- ------------ ---------------------------
**v1 Column** **v2 Column** **Action** **Notes**
id id Copy Preserve UUID
domain_id domain_id Copy
area_id area_id Copy Nullable preserved
name name Copy
description description Copy
status status Map v1 \'archived\' -\> v2
\'archived\' (kept as-is)
due_date target_date Rename Column rename only, same
DATE type
created_at created_at Copy
updated_at updated_at Copy
(none) start_date Default NULL
(none) priority Default 3 (normal)
(none) is_active Default true
(none) sort_order Generate Sequential per area/domain
(none) color Default NULL
(none) release_id Default NULL - no releases in v1
----------------- ----------------- ------------ ---------------------------
**3.4 tasks -\> tasks**
**Mapping:** Direct 1:1 with significant new columns. Preserve UUIDs.
This is the most data-rich migration.
------------------- ------------------- ------------ ---------------------------
**v1 Column** **v2 Column** **Action** **Notes**
id id Copy Preserve UUID - many FKs
depend on this
domain_id domain_id Copy
project_id project_id Copy Nullable preserved
parent_id parent_id Copy Self-ref FK for subtasks
title title Copy
description description Copy
priority priority Copy 1-4 scale preserved
status status Copy Same enum values
due_date due_date Copy
deadline deadline Copy
recurrence recurrence Copy
tags tags Copy TEXT\[\] preserved
context context Copy
is_custom_context is_custom_context Copy
created_at created_at Copy
updated_at updated_at Copy
completed_at completed_at Copy
(none) assigned_to Default NULL - FK to contacts
(none) estimated_minutes Default NULL
(none) actual_minutes Default NULL
(none) energy_level Default NULL (low/medium/high)
(none) is_active Default true
(none) sort_order Generate Sequential per project
(none) template_id Default NULL
------------------- ------------------- ------------ ---------------------------
**3.5 notes -\> notes**
**Mapping:** Direct 1:1. Preserve UUIDs.
----------------- ----------------- ------------ ---------------------------
**v1 Column** **v2 Column** **Action** **Notes**
id id Copy Preserve UUID
domain_id domain_id Copy
project_id project_id Copy
task_id task_id Copy
title title Copy
body body Copy Markdown content preserved
as-is
content_format content_format Copy
tags tags Copy
created_at created_at Copy
updated_at updated_at Copy
(none) is_pinned Default false
(none) is_active Default true
(none) sort_order Default 0
----------------- ----------------- ------------ ---------------------------
**3.6 links -\> bookmarks**
**Mapping:** Renamed table. v2 expands links into a full
bookmark/weblink directory. Preserve UUIDs.
----------------- ----------------- ------------ ---------------------------
**v1 Column** **v2 Column** **Action** **Notes**
id id Copy Preserve UUID
domain_id domain_id Copy
project_id project_id Copy
task_id task_id Copy
label label Copy
url url Copy
description description Copy
created_at created_at Copy
(none) updated_at Generate Set to created_at
(none) folder_id Default NULL - bookmark folders are
new in v2
(none) favicon_url Default NULL
(none) is_active Default true
(none) sort_order Default 0
(none) tags Default NULL - new in v2
----------------- ----------------- ------------ ---------------------------
**3.7 files -\> files**
**Mapping:** 1:1 with storage path transformation. Files must be
downloaded from Supabase Storage and re-uploaded to local disk on
defiant-01.
------------------- ------------------- ------------ ---------------------------
**v1 Column** **v2 Column** **Action** **Notes**
id id Copy Preserve UUID
domain_id domain_id Copy
project_id project_id Copy
task_id task_id Copy
capture_id capture_id Copy
filename filename Copy Internal UUID-prefixed name
original_filename original_filename Copy
storage_path storage_path Transform Rewrite from Supabase path
to local path
mime_type mime_type Copy
size_bytes size_bytes Copy
description description Copy
tags tags Copy
created_at created_at Copy
updated_at updated_at Copy
(none) note_id Default NULL - new FK in v2
(none) is_active Default true
------------------- ------------------- ------------ ---------------------------
**File storage migration:** Use the Supabase Python client to iterate
the life-os-files bucket, download each file, and save to
/opt/lifeos/storage/files/ on defiant-01. Update storage_path values to
reflect the new local path.
**3.8 daily_focus -\> daily_focus**
**Mapping:** Direct 1:1. Preserve UUIDs.
----------------- ----------------- ------------ ---------------------------
**v1 Column** **v2 Column** **Action** **Notes**
id id Copy
focus_date focus_date Copy
task_id task_id Copy
slot slot Copy v2 removes the 3-item limit
completed completed Copy
note note Copy
created_at created_at Copy
(none) domain_id Derive Look up from task_id -\>
tasks.domain_id
----------------- ----------------- ------------ ---------------------------
**3.9 capture -\> capture**
**Mapping:** 1:1 with enrichment for new capture context fields.
----------------- ----------------- ------------ ---------------------------
**v1 Column** **v2 Column** **Action** **Notes**
id id Copy
raw_text raw_text Copy
processed processed Copy
task_id task_id Copy
created_at created_at Copy
(none) domain_id Default NULL - new optional context
during capture
(none) project_id Default NULL
(none) source Default \'web\' - v2 tracks capture
source (web/voice/telegram)
(none) updated_at Generate Set to created_at
----------------- ----------------- ------------ ---------------------------
**3.10 context_types -\> context_types**
**Mapping:** Direct copy. Small reference table.
----------------- ----------------- ------------ ---------------------------
**v1 Column** **v2 Column** **Action** **Notes**
id id Copy v1 uses UUID, v2 keeps UUID
for consistency
value value Copy
label label Copy
is_system is_system Copy
(none) is_active Default true
(none) sort_order Default Sequential
----------------- ----------------- ------------ ---------------------------
**3.11 reminders -\> reminders (redesigned)**
**Mapping:** v1 reminders is task-only with 0 rows. v2 redesigns
reminders as polymorphic (can remind about tasks, events, projects, or
arbitrary items). Since v1 has no data, this is seed-only with no
migration.
v2 reminders table adds: entity_type (TEXT), entity_id (UUID),
recurrence, snoozed_until, and removes the task_id-only FK in favor of
polymorphic reference.
**4. New Tables in v2 (No v1 Data)**
These tables exist only in v2 and will be empty after migration. They
are populated through normal application use.
------------------- ----------------------------------------------------
**Table** **Purpose**
contacts People for task assignment and project management
contact_groups Grouping contacts (team, family, etc.)
lists Named checklists and note lists
list_items Individual items within a list
calendar_events Appointments, meetings, date-based items
time_entries Time tracking records against tasks
time_blocks Scheduled time blocks (Pomodoro, deep work)
time_budgets Weekly/monthly time allocation targets
releases Release/version grouping for projects
milestones Project milestones with target dates
task_dependencies Task-to-task dependency relationships
task_templates Reusable task templates
note_links Cross-references between notes and other entities
bookmark_folders Hierarchical folder structure for bookmarks
tags Normalized tag table (replaces TEXT\[\] arrays
eventually)
entity_tags Junction table for normalized tagging
activity_log Audit trail of entity changes
user_settings Application preferences and configuration
saved_views Custom filtered/sorted views the user saves
search_index Full-text search materialized view / helper
------------------- ----------------------------------------------------
**5. Migration Script Approach**
**5.1 Prerequisites**
1\. pg_dump export of v1 Supabase database saved as
life_os_v1_backup.sql
2\. v2 PostgreSQL database created on defiant-01 (lifeos_dev for
testing, lifeos_prod for final)
3\. v2 schema DDL applied to the target database (see Section 6)
4\. Supabase Storage files downloaded to a local staging directory
5\. Python 3.11+ with psycopg2 and supabase client libraries
**5.2 Script Structure**
migrate_v1_to_v2.py
1\. Connect to v1 (read-only) and v2 (read-write)
2\. For each table in dependency order:
a\. SELECT \* FROM v1 table
b\. Transform each row per mapping rules above
c\. INSERT INTO v2 table
3\. Download files from Supabase Storage
4\. Verify row counts match
5\. Run FK integrity checks on v2
Table order (respects FK dependencies):
domains
areas
projects
context_types
tasks
notes
capture
bookmarks (from links)
files
daily_focus
**5.3 Transformation Rules Summary**
For all tables with missing updated_at: set to created_at.
For all tables with missing is_active: set to true.
For all tables with missing sort_order: assign sequential values (10,
20, 30) within their parent scope.
For projects.due_date: rename to target_date, no value change.
For links -\> bookmarks: table rename, add updated_at = created_at.
For files.storage_path: rewrite from Supabase bucket URL to local
filesystem path.
For daily_focus: derive domain_id by joining through task_id to
tasks.domain_id.
**5.4 Validation Checklist**
After migration, verify:
1\. Row counts: v2 table row count \>= v1 for every mapped table
2\. UUID preservation: SELECT id FROM v2.domains EXCEPT SELECT id FROM
v1.domains should be empty
3\. FK integrity: No orphaned foreign keys in v2
4\. File accessibility: Every file in v2.files table can be served from
local storage
5\. Note content: Spot-check 5 notes for body content integrity
6\. Task hierarchy: Verify parent_id chains are intact
**6. Platform Migration Summary**
----------------------- ----------------------- ------------------------
**Component** **v1 (Old)** **v2 (New)**
Database Supabase (managed Self-hosted PostgreSQL
PostgreSQL) on Hetzner
Application Server Render (web service) Docker container on
Hetzner VM
Reverse Proxy Render (built-in) Nginx on defiant-01
File Storage Supabase Storage Local filesystem
(S3-backed) (/opt/lifeos/storage/)
Data Access Layer supabase Python client SQLAlchemy + psycopg2
(REST) (direct SQL)
Templating Jinja2 Jinja2 (unchanged)
Backend Framework FastAPI FastAPI (unchanged)
Frontend Vanilla HTML/CSS/JS Vanilla HTML/CSS/JS
(redesigned UI)
Dev/Prod Separation Separate Supabase Docker Compose with
projects dev/prod configs
Backups Manual pg_dump Automated cron pg_dump
to /opt/lifeos/backups/
Domain/SSL \*.onrender.com lifeos.invixiom.com with
Let\'s Encrypt
----------------------- ----------------------- ------------------------
**7. Data Access Layer Migration**
Every Supabase client call in the v1 routers must be replaced. The
pattern is consistent:
\# v1 (Supabase REST client)
data = supabase.table(\'tasks\').select(\'\*\').eq(\'project_id\',
pid).execute()
rows = data.data
\# v2 (SQLAlchemy / raw SQL)
rows = db.execute(
text(\'SELECT \* FROM tasks WHERE project_id = :pid\'),
{\'pid\': pid}
).fetchall()
This transformation applies to every router file. The Jinja2 templates
remain unchanged because they consume the same data shape (list of
dicts). The migration is purely at the data access layer.
**8. Rollback Plan**
v1 on Supabase/Render remains untouched and running throughout the
migration. If v2 has issues:
1\. Point DNS back to Render (or simply use the .onrender.com URL
directly)
2\. v1 database on Supabase is read-only but intact - no data was
deleted
3\. Any data created in v2 after migration would need manual
reconciliation if rolling back
Recommended approach: run v1 and v2 in parallel for 1-2 weeks. Cut over
to v2 only after confirming data integrity and feature parity on the
critical path (tasks, focus, notes, capture).
Life OS v2 Migration Plan // Generated February 2026

View File

@@ -0,0 +1,263 @@
-- =============================================================================
-- Life OS - R0 to R1 Data Migration (FIXED)
-- Source: lifeos_prod (R0 schema - actual)
-- Target: lifeos_dev (R1 schema)
--
-- PREREQUISITE: R1 schema must already be applied to lifeos_dev
-- RUN FROM: lifeos_dev database as postgres user
-- =============================================================================
CREATE EXTENSION IF NOT EXISTS dblink;
-- =============================================================================
-- 1. DOMAINS
-- R0: id, name, color, created_at
-- R1: + description, icon, sort_order, is_deleted, deleted_at, updated_at, search_vector
-- =============================================================================
INSERT INTO domains (id, name, color, sort_order, is_deleted, created_at, updated_at)
SELECT id, name, color,
(ROW_NUMBER() OVER (ORDER BY created_at))::INTEGER * 10,
false, created_at, created_at
FROM dblink('dbname=lifeos_prod', '
SELECT id, name, color, created_at FROM domains
') AS r0(id UUID, name TEXT, color TEXT, created_at TIMESTAMPTZ);
-- =============================================================================
-- 2. AREAS
-- R0: id, domain_id, name, description, status, created_at
-- R1: + icon, color, sort_order, is_deleted, deleted_at, updated_at, search_vector
-- =============================================================================
INSERT INTO areas (id, domain_id, name, description, status, sort_order, is_deleted, created_at, updated_at)
SELECT id, domain_id, name, description, COALESCE(status, 'active'),
(ROW_NUMBER() OVER (PARTITION BY domain_id ORDER BY created_at))::INTEGER * 10,
false, created_at, created_at
FROM dblink('dbname=lifeos_prod', '
SELECT id, domain_id, name, description, status, created_at FROM areas
') AS r0(id UUID, domain_id UUID, name TEXT, description TEXT, status TEXT, created_at TIMESTAMPTZ);
-- =============================================================================
-- 3. PROJECTS
-- R0: id, domain_id, name, description, status, priority, start_date,
-- target_date, completed_at, tags, created_at, updated_at, area_id
-- R1: + color, sort_order, is_deleted, deleted_at, search_vector
-- =============================================================================
INSERT INTO projects (id, domain_id, area_id, name, description, status, priority,
start_date, target_date, completed_at, tags, sort_order, is_deleted, created_at, updated_at)
SELECT id, domain_id, area_id, name, description,
COALESCE(status, 'active'), COALESCE(priority, 3),
start_date, target_date, completed_at, tags,
(ROW_NUMBER() OVER (PARTITION BY domain_id ORDER BY created_at))::INTEGER * 10,
false, created_at, COALESCE(updated_at, created_at)
FROM dblink('dbname=lifeos_prod', '
SELECT id, domain_id, area_id, name, description, status, priority,
start_date, target_date, completed_at, tags, created_at, updated_at
FROM projects
') AS r0(
id UUID, domain_id UUID, area_id UUID, name TEXT, description TEXT,
status TEXT, priority INTEGER, start_date DATE, target_date DATE,
completed_at TIMESTAMPTZ, tags TEXT[], created_at TIMESTAMPTZ, updated_at TIMESTAMPTZ
);
-- =============================================================================
-- 4. TASKS
-- R0: id, domain_id, project_id, parent_id, title, description, priority,
-- status, due_date, deadline, recurrence, tags, context, is_custom_context,
-- created_at, updated_at, completed_at
-- R1: + area_id, release_id, estimated_minutes, energy_required,
-- waiting_for_contact_id, waiting_since, import_batch_id,
-- sort_order, is_deleted, deleted_at, search_vector
-- NOTE: R0 has no area_id on tasks. Left NULL in R1.
-- =============================================================================
INSERT INTO tasks (id, domain_id, project_id, parent_id, title, description,
priority, status, due_date, deadline, recurrence, tags, context,
is_custom_context, sort_order, is_deleted, created_at, updated_at, completed_at)
SELECT id, domain_id, project_id, parent_id, title, description,
COALESCE(priority, 3), COALESCE(status, 'open'),
due_date, deadline, recurrence, tags, context,
COALESCE(is_custom_context, false),
(ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY created_at))::INTEGER * 10,
false, created_at, COALESCE(updated_at, created_at), completed_at
FROM dblink('dbname=lifeos_prod', '
SELECT id, domain_id, project_id, parent_id, title, description,
priority, status, due_date, deadline, recurrence, tags, context,
is_custom_context, created_at, updated_at, completed_at
FROM tasks
') AS r0(
id UUID, domain_id UUID, project_id UUID, parent_id UUID,
title TEXT, description TEXT, priority INTEGER, status TEXT,
due_date DATE, deadline TIMESTAMPTZ, recurrence TEXT, tags TEXT[],
context TEXT, is_custom_context BOOLEAN,
created_at TIMESTAMPTZ, updated_at TIMESTAMPTZ, completed_at TIMESTAMPTZ
);
-- =============================================================================
-- 5. NOTES
-- R0: id, domain_id, project_id, task_id, title, body, tags,
-- created_at, updated_at, content_format (default 'markdown')
-- R1: + folder_id, meeting_id, is_meeting_note, sort_order,
-- is_deleted, deleted_at, search_vector
-- Transform: content_format 'markdown' -> 'rich'
-- NOTE: R0 task_id dropped (no equivalent in R1 notes).
-- =============================================================================
INSERT INTO notes (id, domain_id, project_id, title, body, content_format, tags,
is_meeting_note, sort_order, is_deleted, created_at, updated_at)
SELECT id, domain_id, project_id,
CASE WHEN title IS NULL OR title = '' THEN 'Untitled Note' ELSE title END,
body,
CASE WHEN content_format = 'markdown' THEN 'rich' ELSE COALESCE(content_format, 'rich') END,
tags, false,
(ROW_NUMBER() OVER (ORDER BY created_at))::INTEGER * 10,
false, created_at, COALESCE(updated_at, created_at)
FROM dblink('dbname=lifeos_prod', '
SELECT id, domain_id, project_id, title, body, content_format, tags,
created_at, updated_at
FROM notes
') AS r0(
id UUID, domain_id UUID, project_id UUID, title TEXT, body TEXT,
content_format TEXT, tags TEXT[],
created_at TIMESTAMPTZ, updated_at TIMESTAMPTZ
);
-- =============================================================================
-- 6. LINKS
-- R0: id, domain_id, project_id, task_id, label, url, description, created_at
-- R1: + area_id, tags, sort_order, is_deleted, deleted_at, updated_at, search_vector
-- NOTE: R0 task_id dropped (no equivalent in R1 links).
-- =============================================================================
INSERT INTO links (id, domain_id, project_id, label, url, description,
sort_order, is_deleted, created_at, updated_at)
SELECT id, domain_id, project_id, label, url, description,
(ROW_NUMBER() OVER (ORDER BY created_at))::INTEGER * 10,
false, created_at, created_at
FROM dblink('dbname=lifeos_prod', '
SELECT id, domain_id, project_id, label, url, description, created_at
FROM links
') AS r0(
id UUID, domain_id UUID, project_id UUID,
label TEXT, url TEXT, description TEXT, created_at TIMESTAMPTZ
);
-- =============================================================================
-- 7. DAILY FOCUS
-- R0: id, focus_date, task_id, slot, completed, note, created_at
-- R1: + sort_order, is_deleted, deleted_at
-- =============================================================================
INSERT INTO daily_focus (id, focus_date, task_id, slot, completed, note,
sort_order, is_deleted, created_at)
SELECT id, focus_date, task_id, slot, COALESCE(completed, false), note,
COALESCE(slot, (ROW_NUMBER() OVER (PARTITION BY focus_date ORDER BY created_at))::INTEGER) * 10,
false, created_at
FROM dblink('dbname=lifeos_prod', '
SELECT id, focus_date, task_id, slot, completed, note, created_at
FROM daily_focus
') AS r0(
id UUID, focus_date DATE, task_id UUID, slot INTEGER,
completed BOOLEAN, note TEXT, created_at TIMESTAMPTZ
);
-- =============================================================================
-- 8. CAPTURE
-- R0: id, raw_text, processed, task_id, created_at
-- R1: + converted_to_type, converted_to_id, area_id, project_id, list_id,
-- import_batch_id, sort_order, is_deleted, deleted_at
-- Map: R0 task_id -> R1 converted_to_type='task', converted_to_id=task_id
-- =============================================================================
INSERT INTO capture (id, raw_text, processed, converted_to_type, converted_to_id,
sort_order, is_deleted, created_at)
SELECT id, raw_text, COALESCE(processed, false),
CASE WHEN task_id IS NOT NULL THEN 'task' ELSE NULL END,
task_id,
(ROW_NUMBER() OVER (ORDER BY created_at))::INTEGER * 10,
false, created_at
FROM dblink('dbname=lifeos_prod', '
SELECT id, raw_text, processed, task_id, created_at FROM capture
') AS r0(
id UUID, raw_text TEXT, processed BOOLEAN, task_id UUID, created_at TIMESTAMPTZ
);
-- =============================================================================
-- 9. CONTEXT TYPES
-- R0: id (SERIAL), name, description, is_system
-- R1: id (SERIAL), value, label, description, is_system, sort_order, is_deleted
-- Map: R0.name -> R1.value, generate label from name via INITCAP
-- =============================================================================
DELETE FROM context_types;
INSERT INTO context_types (id, value, label, description, is_system, sort_order, is_deleted)
SELECT id, name,
INITCAP(REPLACE(name, '_', ' ')),
description, COALESCE(is_system, true),
id * 10,
false
FROM dblink('dbname=lifeos_prod', '
SELECT id, name, description, is_system FROM context_types
') AS r0(id INTEGER, name TEXT, description TEXT, is_system BOOLEAN);
SELECT setval('context_types_id_seq', GREATEST((SELECT MAX(id) FROM context_types), 1));
-- =============================================================================
-- VERIFICATION
-- =============================================================================
DO $$
DECLARE
r0_domains INTEGER;
r0_areas INTEGER;
r0_projects INTEGER;
r0_tasks INTEGER;
r0_notes INTEGER;
r0_links INTEGER;
r0_daily_focus INTEGER;
r0_capture INTEGER;
r0_context INTEGER;
r1_domains INTEGER;
r1_areas INTEGER;
r1_projects INTEGER;
r1_tasks INTEGER;
r1_notes INTEGER;
r1_links INTEGER;
r1_daily_focus INTEGER;
r1_capture INTEGER;
r1_context INTEGER;
BEGIN
SELECT count INTO r0_domains FROM dblink('dbname=lifeos_prod', 'SELECT count(*) FROM domains') AS t(count INTEGER);
SELECT count INTO r0_areas FROM dblink('dbname=lifeos_prod', 'SELECT count(*) FROM areas') AS t(count INTEGER);
SELECT count INTO r0_projects FROM dblink('dbname=lifeos_prod', 'SELECT count(*) FROM projects') AS t(count INTEGER);
SELECT count INTO r0_tasks FROM dblink('dbname=lifeos_prod', 'SELECT count(*) FROM tasks') AS t(count INTEGER);
SELECT count INTO r0_notes FROM dblink('dbname=lifeos_prod', 'SELECT count(*) FROM notes') AS t(count INTEGER);
SELECT count INTO r0_links FROM dblink('dbname=lifeos_prod', 'SELECT count(*) FROM links') AS t(count INTEGER);
SELECT count INTO r0_daily_focus FROM dblink('dbname=lifeos_prod', 'SELECT count(*) FROM daily_focus') AS t(count INTEGER);
SELECT count INTO r0_capture FROM dblink('dbname=lifeos_prod', 'SELECT count(*) FROM capture') AS t(count INTEGER);
SELECT count INTO r0_context FROM dblink('dbname=lifeos_prod', 'SELECT count(*) FROM context_types') AS t(count INTEGER);
SELECT count(*) INTO r1_domains FROM domains;
SELECT count(*) INTO r1_areas FROM areas;
SELECT count(*) INTO r1_projects FROM projects;
SELECT count(*) INTO r1_tasks FROM tasks;
SELECT count(*) INTO r1_notes FROM notes;
SELECT count(*) INTO r1_links FROM links;
SELECT count(*) INTO r1_daily_focus FROM daily_focus;
SELECT count(*) INTO r1_capture FROM capture;
SELECT count(*) INTO r1_context FROM context_types;
RAISE NOTICE '=== MIGRATION VERIFICATION ===';
RAISE NOTICE 'domains: R0=% R1=% %', r0_domains, r1_domains, CASE WHEN r0_domains = r1_domains THEN 'OK' ELSE 'MISMATCH' END;
RAISE NOTICE 'areas: R0=% R1=% %', r0_areas, r1_areas, CASE WHEN r0_areas = r1_areas THEN 'OK' ELSE 'MISMATCH' END;
RAISE NOTICE 'projects: R0=% R1=% %', r0_projects, r1_projects, CASE WHEN r0_projects = r1_projects THEN 'OK' ELSE 'MISMATCH' END;
RAISE NOTICE 'tasks: R0=% R1=% %', r0_tasks, r1_tasks, CASE WHEN r0_tasks = r1_tasks THEN 'OK' ELSE 'MISMATCH' END;
RAISE NOTICE 'notes: R0=% R1=% %', r0_notes, r1_notes, CASE WHEN r0_notes = r1_notes THEN 'OK' ELSE 'MISMATCH' END;
RAISE NOTICE 'links: R0=% R1=% %', r0_links, r1_links, CASE WHEN r0_links = r1_links THEN 'OK' ELSE 'MISMATCH' END;
RAISE NOTICE 'daily_focus: R0=% R1=% %', r0_daily_focus, r1_daily_focus, CASE WHEN r0_daily_focus = r1_daily_focus THEN 'OK' ELSE 'MISMATCH' END;
RAISE NOTICE 'capture: R0=% R1=% %', r0_capture, r1_capture, CASE WHEN r0_capture = r1_capture THEN 'OK' ELSE 'MISMATCH' END;
RAISE NOTICE 'context_types:R0=% R1=% %', r0_context, r1_context, CASE WHEN r0_context = r1_context THEN 'OK' ELSE 'MISMATCH' END;
RAISE NOTICE '=== END VERIFICATION ===';
END $$;

View File

@@ -0,0 +1,979 @@
-- =============================================================================
-- Life OS - Release 1 COMPLETE Schema
-- Self-hosted PostgreSQL 16 on defiant-01 (Hetzner)
-- Database: lifeos_dev
-- Generated from Architecture Design Document v2.0
-- =============================================================================
-- Extensions
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- =============================================================================
-- LOOKUP TABLE: Context Types
-- =============================================================================
CREATE TABLE context_types (
id SERIAL PRIMARY KEY,
value TEXT NOT NULL UNIQUE,
label TEXT NOT NULL,
description TEXT,
is_system BOOLEAN NOT NULL DEFAULT true,
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- CORE HIERARCHY
-- =============================================================================
CREATE TABLE domains (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
color TEXT,
description TEXT,
icon TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE areas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
domain_id UUID NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
icon TEXT,
color TEXT,
status TEXT NOT NULL DEFAULT 'active',
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
domain_id UUID NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
area_id UUID REFERENCES areas(id) ON DELETE SET NULL,
name TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'active',
priority INTEGER NOT NULL DEFAULT 3,
start_date DATE,
target_date DATE,
completed_at TIMESTAMPTZ,
color TEXT,
tags TEXT[],
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Forward-declare releases for tasks.release_id FK
CREATE TABLE releases (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
version_label TEXT,
description TEXT,
status TEXT NOT NULL DEFAULT 'planned',
target_date DATE,
released_at DATE,
release_notes TEXT,
tags TEXT[],
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Forward-declare contacts for tasks.waiting_for_contact_id FK
CREATE TABLE contacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
first_name TEXT NOT NULL,
last_name TEXT,
company TEXT,
role TEXT,
email TEXT,
phone TEXT,
notes TEXT,
tags TEXT[],
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
domain_id UUID REFERENCES domains(id) ON DELETE CASCADE,
area_id UUID REFERENCES areas(id) ON DELETE SET NULL,
project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
release_id UUID REFERENCES releases(id) ON DELETE SET NULL,
parent_id UUID REFERENCES tasks(id) ON DELETE SET NULL,
title TEXT NOT NULL,
description TEXT,
priority INTEGER NOT NULL DEFAULT 3,
status TEXT NOT NULL DEFAULT 'open',
due_date DATE,
deadline TIMESTAMPTZ,
recurrence TEXT,
estimated_minutes INTEGER,
energy_required TEXT,
context TEXT,
is_custom_context BOOLEAN NOT NULL DEFAULT false,
waiting_for_contact_id UUID REFERENCES contacts(id) ON DELETE SET NULL,
waiting_since DATE,
import_batch_id UUID,
tags TEXT[],
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- KNOWLEDGE MANAGEMENT
-- =============================================================================
CREATE TABLE note_folders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
parent_id UUID REFERENCES note_folders(id) ON DELETE CASCADE,
name TEXT NOT NULL,
auto_generated BOOLEAN NOT NULL DEFAULT false,
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Forward-declare meetings for notes.meeting_id FK
CREATE TABLE meetings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
parent_id UUID REFERENCES meetings(id) ON DELETE SET NULL,
title TEXT NOT NULL,
meeting_date DATE NOT NULL,
start_at TIMESTAMPTZ,
end_at TIMESTAMPTZ,
location TEXT,
status TEXT NOT NULL DEFAULT 'scheduled',
priority INTEGER,
recurrence TEXT,
agenda TEXT,
transcript TEXT,
notes_body TEXT,
tags TEXT[],
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
domain_id UUID REFERENCES domains(id) ON DELETE CASCADE,
project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
folder_id UUID REFERENCES note_folders(id) ON DELETE SET NULL,
meeting_id UUID REFERENCES meetings(id) ON DELETE SET NULL,
title TEXT NOT NULL,
body TEXT,
content_format TEXT NOT NULL DEFAULT 'rich',
is_meeting_note BOOLEAN NOT NULL DEFAULT false,
tags TEXT[],
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE decisions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
rationale TEXT,
status TEXT NOT NULL DEFAULT 'proposed',
impact TEXT NOT NULL DEFAULT 'medium',
decided_at DATE,
meeting_id UUID REFERENCES meetings(id) ON DELETE SET NULL,
superseded_by_id UUID REFERENCES decisions(id) ON DELETE SET NULL,
tags TEXT[],
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE lists (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
domain_id UUID REFERENCES domains(id) ON DELETE CASCADE,
area_id UUID REFERENCES areas(id) ON DELETE SET NULL,
project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
name TEXT NOT NULL,
list_type TEXT NOT NULL DEFAULT 'checklist',
description TEXT,
tags TEXT[],
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE list_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
list_id UUID NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
parent_item_id UUID REFERENCES list_items(id) ON DELETE SET NULL,
content TEXT NOT NULL,
completed BOOLEAN NOT NULL DEFAULT false,
completed_at TIMESTAMPTZ,
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
domain_id UUID REFERENCES domains(id) ON DELETE CASCADE,
area_id UUID REFERENCES areas(id) ON DELETE SET NULL,
project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
label TEXT NOT NULL,
url TEXT NOT NULL,
description TEXT,
tags TEXT[],
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
filename TEXT NOT NULL,
original_filename TEXT NOT NULL,
storage_path TEXT NOT NULL,
mime_type TEXT,
size_bytes INTEGER,
description TEXT,
tags TEXT[],
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- SYSTEM LEVEL: Appointments
-- =============================================================================
CREATE TABLE appointments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
description TEXT,
location TEXT,
start_at TIMESTAMPTZ NOT NULL,
end_at TIMESTAMPTZ,
all_day BOOLEAN NOT NULL DEFAULT false,
recurrence TEXT,
tags TEXT[],
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- SYSTEM LEVEL: Milestones
-- =============================================================================
CREATE TABLE milestones (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
release_id UUID REFERENCES releases(id) ON DELETE SET NULL,
project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
name TEXT NOT NULL,
target_date DATE NOT NULL,
completed_at DATE,
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- SYSTEM LEVEL: Processes
-- =============================================================================
CREATE TABLE processes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT,
process_type TEXT NOT NULL DEFAULT 'checklist',
category TEXT,
status TEXT NOT NULL DEFAULT 'draft',
tags TEXT[],
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE process_steps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
process_id UUID NOT NULL REFERENCES processes(id) ON DELETE CASCADE,
title TEXT NOT NULL,
instructions TEXT,
expected_output TEXT,
estimated_days INTEGER,
context TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE process_runs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
process_id UUID NOT NULL REFERENCES processes(id) ON DELETE CASCADE,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'not_started',
process_type TEXT NOT NULL,
task_generation TEXT NOT NULL DEFAULT 'all_at_once',
project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
contact_id UUID REFERENCES contacts(id) ON DELETE SET NULL,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE process_run_steps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
run_id UUID NOT NULL REFERENCES process_runs(id) ON DELETE CASCADE,
title TEXT NOT NULL,
instructions TEXT,
status TEXT NOT NULL DEFAULT 'pending',
completed_by_id UUID REFERENCES contacts(id) ON DELETE SET NULL,
completed_at TIMESTAMPTZ,
notes TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- SYSTEM LEVEL: Daily Focus
-- =============================================================================
CREATE TABLE daily_focus (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
focus_date DATE NOT NULL,
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
slot INTEGER,
completed BOOLEAN NOT NULL DEFAULT false,
note TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- SYSTEM LEVEL: Capture Queue
-- =============================================================================
CREATE TABLE capture (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
raw_text TEXT NOT NULL,
processed BOOLEAN NOT NULL DEFAULT false,
converted_to_type TEXT,
converted_to_id UUID,
area_id UUID REFERENCES areas(id) ON DELETE SET NULL,
project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
list_id UUID REFERENCES lists(id) ON DELETE SET NULL,
import_batch_id UUID,
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- SYSTEM LEVEL: Task Templates
-- =============================================================================
CREATE TABLE task_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT,
priority INTEGER,
estimated_minutes INTEGER,
energy_required TEXT,
context TEXT,
tags TEXT[],
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE task_template_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
template_id UUID NOT NULL REFERENCES task_templates(id) ON DELETE CASCADE,
title TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- TIME MANAGEMENT
-- =============================================================================
CREATE TABLE time_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
start_at TIMESTAMPTZ NOT NULL,
end_at TIMESTAMPTZ,
duration_minutes INTEGER,
notes TEXT,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE time_blocks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
task_id UUID REFERENCES tasks(id) ON DELETE SET NULL,
title TEXT NOT NULL,
context TEXT,
energy TEXT,
start_at TIMESTAMPTZ NOT NULL,
end_at TIMESTAMPTZ NOT NULL,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE time_budgets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
domain_id UUID NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
weekly_hours DECIMAL NOT NULL,
effective_from DATE NOT NULL,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- SYSTEM LEVEL: Weblink Directory
-- =============================================================================
CREATE TABLE weblink_folders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
parent_id UUID REFERENCES weblink_folders(id) ON DELETE CASCADE,
name TEXT NOT NULL,
auto_generated BOOLEAN NOT NULL DEFAULT false,
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE weblinks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
label TEXT NOT NULL,
url TEXT NOT NULL,
description TEXT,
tags TEXT[],
sort_order INTEGER NOT NULL DEFAULT 0,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- SYSTEM LEVEL: Reminders (polymorphic)
-- =============================================================================
CREATE TABLE reminders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
entity_type TEXT NOT NULL,
entity_id UUID NOT NULL,
remind_at TIMESTAMPTZ NOT NULL,
note TEXT,
delivered BOOLEAN NOT NULL DEFAULT false,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- UNIVERSAL: Dependencies (polymorphic DAG)
-- =============================================================================
CREATE TABLE dependencies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
blocker_type TEXT NOT NULL,
blocker_id UUID NOT NULL,
dependent_type TEXT NOT NULL,
dependent_id UUID NOT NULL,
dependency_type TEXT NOT NULL DEFAULT 'finish_to_start',
lag_days INTEGER NOT NULL DEFAULT 0,
note TEXT,
is_deleted BOOLEAN NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (blocker_type, blocker_id, dependent_type, dependent_id, dependency_type),
CHECK (NOT (blocker_type = dependent_type AND blocker_id = dependent_id))
);
-- =============================================================================
-- JUNCTION TABLES
-- =============================================================================
-- Notes <-> Projects (M2M)
CREATE TABLE note_projects (
note_id UUID NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
is_primary BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (note_id, project_id)
);
-- Notes <-> Notes (wiki graph)
CREATE TABLE note_links (
source_note_id UUID NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
target_note_id UUID NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (source_note_id, target_note_id)
);
-- Files <-> any entity (polymorphic M2M)
CREATE TABLE file_mappings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
file_id UUID NOT NULL REFERENCES files(id) ON DELETE CASCADE,
context_type TEXT NOT NULL,
context_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (file_id, context_type, context_id)
);
-- Releases <-> Projects (M2M)
CREATE TABLE release_projects (
release_id UUID NOT NULL REFERENCES releases(id) ON DELETE CASCADE,
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (release_id, project_id)
);
-- Releases <-> Domains (M2M)
CREATE TABLE release_domains (
release_id UUID NOT NULL REFERENCES releases(id) ON DELETE CASCADE,
domain_id UUID NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (release_id, domain_id)
);
-- Contacts <-> Tasks
CREATE TABLE contact_tasks (
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
role TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (contact_id, task_id)
);
-- Contacts <-> Projects
CREATE TABLE contact_projects (
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
role TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (contact_id, project_id)
);
-- Contacts <-> Lists
CREATE TABLE contact_lists (
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
list_id UUID NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
role TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (contact_id, list_id)
);
-- Contacts <-> List Items
CREATE TABLE contact_list_items (
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
list_item_id UUID NOT NULL REFERENCES list_items(id) ON DELETE CASCADE,
role TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (contact_id, list_item_id)
);
-- Contacts <-> Appointments
CREATE TABLE contact_appointments (
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
appointment_id UUID NOT NULL REFERENCES appointments(id) ON DELETE CASCADE,
role TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (contact_id, appointment_id)
);
-- Contacts <-> Meetings
CREATE TABLE contact_meetings (
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
meeting_id UUID NOT NULL REFERENCES meetings(id) ON DELETE CASCADE,
role TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (contact_id, meeting_id)
);
-- Decisions <-> Projects
CREATE TABLE decision_projects (
decision_id UUID NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (decision_id, project_id)
);
-- Decisions <-> Contacts
CREATE TABLE decision_contacts (
decision_id UUID NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
role TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (decision_id, contact_id)
);
-- Meetings <-> Tasks
CREATE TABLE meeting_tasks (
meeting_id UUID NOT NULL REFERENCES meetings(id) ON DELETE CASCADE,
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
source TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (meeting_id, task_id)
);
-- Process Run Steps <-> Tasks
CREATE TABLE process_run_tasks (
run_step_id UUID NOT NULL REFERENCES process_run_steps(id) ON DELETE CASCADE,
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (run_step_id, task_id)
);
-- Weblinks <-> Folders (M2M)
CREATE TABLE folder_weblinks (
folder_id UUID NOT NULL REFERENCES weblink_folders(id) ON DELETE CASCADE,
weblink_id UUID NOT NULL REFERENCES weblinks(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (folder_id, weblink_id)
);
-- =============================================================================
-- INDEXES
-- =============================================================================
-- Sort order indexes
CREATE INDEX idx_domains_sort ON domains(sort_order);
CREATE INDEX idx_areas_sort ON areas(domain_id, sort_order);
CREATE INDEX idx_projects_sort ON projects(domain_id, sort_order);
CREATE INDEX idx_projects_area_sort ON projects(area_id, sort_order);
CREATE INDEX idx_tasks_project_sort ON tasks(project_id, sort_order);
CREATE INDEX idx_tasks_parent_sort ON tasks(parent_id, sort_order);
CREATE INDEX idx_tasks_domain_sort ON tasks(domain_id, sort_order);
CREATE INDEX idx_list_items_sort ON list_items(list_id, sort_order);
CREATE INDEX idx_list_items_parent_sort ON list_items(parent_item_id, sort_order);
CREATE INDEX idx_weblink_folders_sort ON weblink_folders(parent_id, sort_order);
-- Lookup indexes
CREATE INDEX idx_tasks_status ON tasks(status);
CREATE INDEX idx_tasks_due_date ON tasks(due_date);
CREATE INDEX idx_tasks_priority ON tasks(priority);
CREATE INDEX idx_projects_status ON projects(status);
CREATE INDEX idx_daily_focus_date ON daily_focus(focus_date);
CREATE INDEX idx_appointments_start ON appointments(start_at);
CREATE INDEX idx_capture_processed ON capture(processed);
CREATE INDEX idx_file_mappings_context ON file_mappings(context_type, context_id);
CREATE INDEX idx_dependencies_blocker ON dependencies(blocker_type, blocker_id);
CREATE INDEX idx_dependencies_dependent ON dependencies(dependent_type, dependent_id);
CREATE INDEX idx_reminders_entity ON reminders(entity_type, entity_id);
CREATE INDEX idx_time_entries_task ON time_entries(task_id);
CREATE INDEX idx_meetings_date ON meetings(meeting_date);
-- Full-text search GIN indexes
CREATE INDEX idx_domains_search ON domains USING GIN(search_vector);
CREATE INDEX idx_areas_search ON areas USING GIN(search_vector);
CREATE INDEX idx_projects_search ON projects USING GIN(search_vector);
CREATE INDEX idx_tasks_search ON tasks USING GIN(search_vector);
CREATE INDEX idx_notes_search ON notes USING GIN(search_vector);
CREATE INDEX idx_contacts_search ON contacts USING GIN(search_vector);
CREATE INDEX idx_meetings_search ON meetings USING GIN(search_vector);
CREATE INDEX idx_decisions_search ON decisions USING GIN(search_vector);
CREATE INDEX idx_lists_search ON lists USING GIN(search_vector);
CREATE INDEX idx_links_search ON links USING GIN(search_vector);
CREATE INDEX idx_files_search ON files USING GIN(search_vector);
CREATE INDEX idx_weblinks_search ON weblinks USING GIN(search_vector);
CREATE INDEX idx_processes_search ON processes USING GIN(search_vector);
CREATE INDEX idx_appointments_search ON appointments USING GIN(search_vector);
-- =============================================================================
-- SEARCH VECTOR TRIGGERS
-- =============================================================================
CREATE OR REPLACE FUNCTION update_search_vector() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('pg_catalog.english',
coalesce(NEW.title, '') || ' ' ||
coalesce(NEW.description, '') || ' ' ||
coalesce(NEW.name, '') || ' ' ||
coalesce(array_to_string(NEW.tags, ' '), '')
);
RETURN NEW;
EXCEPTION WHEN undefined_column THEN
-- Fallback for tables with different column names
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Per-table triggers with correct columns
CREATE OR REPLACE FUNCTION update_domains_search() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('pg_catalog.english', coalesce(NEW.name, ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_domains_search BEFORE INSERT OR UPDATE ON domains
FOR EACH ROW EXECUTE FUNCTION update_domains_search();
CREATE OR REPLACE FUNCTION update_areas_search() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('pg_catalog.english',
coalesce(NEW.name, '') || ' ' || coalesce(NEW.description, ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_areas_search BEFORE INSERT OR UPDATE ON areas
FOR EACH ROW EXECUTE FUNCTION update_areas_search();
CREATE OR REPLACE FUNCTION update_projects_search() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('pg_catalog.english',
coalesce(NEW.name, '') || ' ' || coalesce(NEW.description, '') || ' ' ||
coalesce(array_to_string(NEW.tags, ' '), ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_projects_search BEFORE INSERT OR UPDATE ON projects
FOR EACH ROW EXECUTE FUNCTION update_projects_search();
CREATE OR REPLACE FUNCTION update_tasks_search() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('pg_catalog.english',
coalesce(NEW.title, '') || ' ' || coalesce(NEW.description, '') || ' ' ||
coalesce(array_to_string(NEW.tags, ' '), ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_tasks_search BEFORE INSERT OR UPDATE ON tasks
FOR EACH ROW EXECUTE FUNCTION update_tasks_search();
CREATE OR REPLACE FUNCTION update_notes_search() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('pg_catalog.english',
coalesce(NEW.title, '') || ' ' || coalesce(NEW.body, '') || ' ' ||
coalesce(array_to_string(NEW.tags, ' '), ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_notes_search BEFORE INSERT OR UPDATE ON notes
FOR EACH ROW EXECUTE FUNCTION update_notes_search();
CREATE OR REPLACE FUNCTION update_contacts_search() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('pg_catalog.english',
coalesce(NEW.first_name, '') || ' ' || coalesce(NEW.last_name, '') || ' ' ||
coalesce(NEW.company, '') || ' ' || coalesce(NEW.email, '') || ' ' ||
coalesce(array_to_string(NEW.tags, ' '), ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_contacts_search BEFORE INSERT OR UPDATE ON contacts
FOR EACH ROW EXECUTE FUNCTION update_contacts_search();
CREATE OR REPLACE FUNCTION update_meetings_search() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('pg_catalog.english',
coalesce(NEW.title, '') || ' ' || coalesce(NEW.agenda, '') || ' ' ||
coalesce(NEW.notes_body, '') || ' ' ||
coalesce(array_to_string(NEW.tags, ' '), ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_meetings_search BEFORE INSERT OR UPDATE ON meetings
FOR EACH ROW EXECUTE FUNCTION update_meetings_search();
CREATE OR REPLACE FUNCTION update_decisions_search() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('pg_catalog.english',
coalesce(NEW.title, '') || ' ' || coalesce(NEW.rationale, '') || ' ' ||
coalesce(array_to_string(NEW.tags, ' '), ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_decisions_search BEFORE INSERT OR UPDATE ON decisions
FOR EACH ROW EXECUTE FUNCTION update_decisions_search();
CREATE OR REPLACE FUNCTION update_lists_search() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('pg_catalog.english',
coalesce(NEW.name, '') || ' ' || coalesce(NEW.description, '') || ' ' ||
coalesce(array_to_string(NEW.tags, ' '), ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_lists_search BEFORE INSERT OR UPDATE ON lists
FOR EACH ROW EXECUTE FUNCTION update_lists_search();
CREATE OR REPLACE FUNCTION update_links_search() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('pg_catalog.english',
coalesce(NEW.label, '') || ' ' || coalesce(NEW.url, '') || ' ' ||
coalesce(NEW.description, '') || ' ' ||
coalesce(array_to_string(NEW.tags, ' '), ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_links_search BEFORE INSERT OR UPDATE ON links
FOR EACH ROW EXECUTE FUNCTION update_links_search();
CREATE OR REPLACE FUNCTION update_files_search() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('pg_catalog.english',
coalesce(NEW.original_filename, '') || ' ' || coalesce(NEW.description, '') || ' ' ||
coalesce(array_to_string(NEW.tags, ' '), ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_files_search BEFORE INSERT OR UPDATE ON files
FOR EACH ROW EXECUTE FUNCTION update_files_search();
CREATE OR REPLACE FUNCTION update_weblinks_search() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('pg_catalog.english',
coalesce(NEW.label, '') || ' ' || coalesce(NEW.url, '') || ' ' ||
coalesce(NEW.description, '') || ' ' ||
coalesce(array_to_string(NEW.tags, ' '), ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_weblinks_search BEFORE INSERT OR UPDATE ON weblinks
FOR EACH ROW EXECUTE FUNCTION update_weblinks_search();
CREATE OR REPLACE FUNCTION update_processes_search() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('pg_catalog.english',
coalesce(NEW.name, '') || ' ' || coalesce(NEW.description, '') || ' ' ||
coalesce(array_to_string(NEW.tags, ' '), ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_processes_search BEFORE INSERT OR UPDATE ON processes
FOR EACH ROW EXECUTE FUNCTION update_processes_search();
CREATE OR REPLACE FUNCTION update_appointments_search() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('pg_catalog.english',
coalesce(NEW.title, '') || ' ' || coalesce(NEW.description, '') || ' ' ||
coalesce(NEW.location, '') || ' ' ||
coalesce(array_to_string(NEW.tags, ' '), ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_appointments_search BEFORE INSERT OR UPDATE ON appointments
FOR EACH ROW EXECUTE FUNCTION update_appointments_search();
CREATE OR REPLACE FUNCTION update_releases_search() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('pg_catalog.english',
coalesce(NEW.name, '') || ' ' || coalesce(NEW.description, '') || ' ' ||
coalesce(NEW.version_label, '') || ' ' ||
coalesce(array_to_string(NEW.tags, ' '), ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_releases_search BEFORE INSERT OR UPDATE ON releases
FOR EACH ROW EXECUTE FUNCTION update_releases_search();
-- =============================================================================
-- SEED DATA: Context Types
-- =============================================================================
INSERT INTO context_types (value, label, is_system, sort_order) VALUES
('deep_work', 'Deep Work', true, 10),
('quick', 'Quick', true, 20),
('waiting', 'Waiting', true, 30),
('someday', 'Someday', true, 40),
('meeting', 'Meeting', true, 50),
('errand', 'Errand', true, 60);

View File

@@ -0,0 +1,358 @@
-- =============================================================================
-- Life OS - Release 1 Schema
-- Self-hosted PostgreSQL on defiant-01 (Hetzner)
-- =============================================================================
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- =============================================================================
-- SYSTEM LEVEL: Context Types
-- =============================================================================
CREATE TABLE context_types (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
label TEXT NOT NULL,
description TEXT,
is_system BOOLEAN NOT NULL DEFAULT true,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- ORGANIZATIONAL HIERARCHY
-- =============================================================================
CREATE TABLE domains (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
color TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE areas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
domain_id UUID NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'active',
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
domain_id UUID NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
area_id UUID REFERENCES areas(id) ON DELETE SET NULL,
name TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'active',
priority INTEGER NOT NULL DEFAULT 3,
start_date DATE,
target_date DATE,
completed_at TIMESTAMPTZ,
tags TEXT[],
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
domain_id UUID NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
area_id UUID REFERENCES areas(id) ON DELETE SET NULL,
project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
parent_id UUID REFERENCES tasks(id) ON DELETE SET NULL,
title TEXT NOT NULL,
description TEXT,
priority INTEGER NOT NULL DEFAULT 3,
status TEXT NOT NULL DEFAULT 'open',
due_date DATE,
deadline TIMESTAMPTZ,
recurrence TEXT,
tags TEXT[],
context TEXT,
is_custom_context BOOLEAN NOT NULL DEFAULT false,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ
);
CREATE TABLE notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
domain_id UUID NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
title TEXT NOT NULL,
body TEXT,
content_format TEXT NOT NULL DEFAULT 'rich',
tags TEXT[],
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE lists (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
domain_id UUID NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
area_id UUID REFERENCES areas(id) ON DELETE SET NULL,
project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
name TEXT NOT NULL,
list_type TEXT NOT NULL DEFAULT 'checklist',
description TEXT,
tags TEXT[],
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE list_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
list_id UUID NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
parent_item_id UUID REFERENCES list_items(id) ON DELETE SET NULL,
content TEXT NOT NULL,
completed BOOLEAN NOT NULL DEFAULT false,
completed_at TIMESTAMPTZ,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
domain_id UUID NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
area_id UUID REFERENCES areas(id) ON DELETE SET NULL,
project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
label TEXT NOT NULL,
url TEXT NOT NULL,
description TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
filename TEXT NOT NULL,
original_filename TEXT NOT NULL,
storage_path TEXT NOT NULL,
mime_type TEXT,
size_bytes INTEGER,
description TEXT,
tags TEXT[],
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- SYSTEM LEVEL: Contacts
-- =============================================================================
CREATE TABLE contacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
company TEXT,
role TEXT,
email TEXT,
phone TEXT,
notes TEXT,
tags TEXT[],
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- SYSTEM LEVEL: Appointments
-- =============================================================================
CREATE TABLE appointments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
description TEXT,
location TEXT,
start_at TIMESTAMPTZ NOT NULL,
end_at TIMESTAMPTZ,
all_day BOOLEAN NOT NULL DEFAULT false,
recurrence TEXT,
tags TEXT[],
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- SYSTEM LEVEL: Weblink Directory
-- =============================================================================
CREATE TABLE weblink_folders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
parent_id UUID REFERENCES weblink_folders(id) ON DELETE CASCADE,
name TEXT NOT NULL,
auto_generated BOOLEAN NOT NULL DEFAULT false,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE weblinks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
label TEXT NOT NULL,
url TEXT NOT NULL,
description TEXT,
tags TEXT[],
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- SYSTEM LEVEL: Daily Focus
-- =============================================================================
CREATE TABLE daily_focus (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
focus_date DATE NOT NULL,
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
slot INTEGER,
completed BOOLEAN NOT NULL DEFAULT false,
note TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- SYSTEM LEVEL: Capture Queue
-- =============================================================================
CREATE TABLE capture (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
raw_text TEXT NOT NULL,
processed BOOLEAN NOT NULL DEFAULT false,
converted_to_type TEXT,
converted_to_id UUID,
area_id UUID REFERENCES areas(id) ON DELETE SET NULL,
project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
list_id UUID REFERENCES lists(id) ON DELETE SET NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- SYSTEM LEVEL: Reminders
-- =============================================================================
CREATE TABLE reminders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
remind_at TIMESTAMPTZ NOT NULL,
delivered BOOLEAN NOT NULL DEFAULT false,
channel TEXT NOT NULL DEFAULT 'web',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- JUNCTION TABLES
-- =============================================================================
-- Notes <-> Projects (M2M)
CREATE TABLE note_projects (
note_id UUID NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
is_primary BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (note_id, project_id)
);
-- Files <-> any entity (polymorphic M2M)
CREATE TABLE file_mappings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
file_id UUID NOT NULL REFERENCES files(id) ON DELETE CASCADE,
context_type TEXT NOT NULL,
context_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (file_id, context_type, context_id)
);
-- Contacts <-> Tasks
CREATE TABLE contact_tasks (
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
role TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (contact_id, task_id)
);
-- Contacts <-> Lists
CREATE TABLE contact_lists (
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
list_id UUID NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
role TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (contact_id, list_id)
);
-- Contacts <-> List Items
CREATE TABLE contact_list_items (
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
list_item_id UUID NOT NULL REFERENCES list_items(id) ON DELETE CASCADE,
role TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (contact_id, list_item_id)
);
-- Contacts <-> Projects
CREATE TABLE contact_projects (
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
role TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (contact_id, project_id)
);
-- Contacts <-> Appointments
CREATE TABLE contact_appointments (
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
appointment_id UUID NOT NULL REFERENCES appointments(id) ON DELETE CASCADE,
role TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (contact_id, appointment_id)
);
-- Weblinks <-> Folders (M2M)
CREATE TABLE folder_weblinks (
folder_id UUID NOT NULL REFERENCES weblink_folders(id) ON DELETE CASCADE,
weblink_id UUID NOT NULL REFERENCES weblinks(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (folder_id, weblink_id)
);
-- =============================================================================
-- INDEXES
-- =============================================================================
-- Sort order indexes (used on every list render)
CREATE INDEX idx_domains_sort ON domains(sort_order);
CREATE INDEX idx_areas_sort ON areas(domain_id, sort_order);
CREATE INDEX idx_projects_sort ON projects(domain_id, sort_order);
CREATE INDEX idx_projects_area_sort ON projects(area_id, sort_order);
CREATE INDEX idx_tasks_project_sort ON tasks(project_id, sort_order);
CREATE INDEX idx_tasks_parent_sort ON tasks(parent_id, sort_order);
CREATE INDEX idx_tasks_domain_sort ON tasks(domain_id, sort_order);
CREATE INDEX idx_list_items_sort ON list_items(list_id, sort_order);
CREATE INDEX idx_list_items_parent_sort ON list_items(parent_item_id, sort_order);
CREATE INDEX idx_weblinks_sort ON weblink_folders(parent_id, sort_order);
-- Lookup indexes
CREATE INDEX idx_tasks_status ON tasks(status);
CREATE INDEX idx_tasks_due_date ON tasks(due_date);
CREATE INDEX idx_tasks_priority ON tasks(priority);
CREATE INDEX idx_projects_status ON projects(status);
CREATE INDEX idx_daily_focus_date ON daily_focus(focus_date);
CREATE INDEX idx_appointments_start ON appointments(start_at);
CREATE INDEX idx_capture_processed ON capture(processed);
CREATE INDEX idx_file_mappings_context ON file_mappings(context_type, context_id);

View File

@@ -0,0 +1,122 @@
#!/bin/bash
# =============================================================================
# Life OS - Step 1: DEV Database Setup
# Applies R1 schema to lifeos_dev, migrates data from lifeos_prod (R0)
# Run on: defiant-01 as root
# =============================================================================
set -e
DB_CONTAINER="lifeos-db"
DB_USER="postgres"
DEV_DB="lifeos_dev"
PROD_DB="lifeos_prod"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
section() {
echo ""
echo "=============================================="
echo " $1"
echo "=============================================="
}
# =============================================================================
# 1. Verify prerequisites
# =============================================================================
section "1. Verifying prerequisites"
echo "Checking lifeos-db container..."
if ! docker ps | grep -q "$DB_CONTAINER"; then
echo "ERROR: $DB_CONTAINER is not running"
exit 1
fi
echo "OK: $DB_CONTAINER is running"
echo "Checking lifeos_dev database exists..."
DEV_EXISTS=$(docker exec $DB_CONTAINER psql -U $DB_USER -tc "SELECT 1 FROM pg_database WHERE datname='$DEV_DB'" | tr -d ' ')
if [ "$DEV_EXISTS" != "1" ]; then
echo "ERROR: $DEV_DB database does not exist"
exit 1
fi
echo "OK: $DEV_DB exists"
echo "Checking lifeos_prod database exists..."
PROD_EXISTS=$(docker exec $DB_CONTAINER psql -U $DB_USER -tc "SELECT 1 FROM pg_database WHERE datname='$PROD_DB'" | tr -d ' ')
if [ "$PROD_EXISTS" != "1" ]; then
echo "ERROR: $PROD_DB database does not exist"
exit 1
fi
echo "OK: $PROD_DB exists"
echo "Checking R0 data in lifeos_prod..."
R0_DOMAINS=$(docker exec $DB_CONTAINER psql -U $DB_USER -d $PROD_DB -tc "SELECT count(*) FROM domains" 2>/dev/null | tr -d ' ')
echo "R0 domains count: $R0_DOMAINS"
if [ "$R0_DOMAINS" = "0" ] || [ -z "$R0_DOMAINS" ]; then
echo "WARNING: No domains found in lifeos_prod. Migration will produce empty tables."
fi
# =============================================================================
# 2. Drop existing R1 tables in lifeos_dev (clean slate)
# =============================================================================
section "2. Cleaning lifeos_dev (drop all tables)"
docker exec $DB_CONTAINER psql -U $DB_USER -d $DEV_DB -c "
DROP SCHEMA public CASCADE;
CREATE SCHEMA public;
GRANT ALL ON SCHEMA public TO $DB_USER;
GRANT ALL ON SCHEMA public TO public;
"
echo "OK: lifeos_dev schema reset"
# =============================================================================
# 3. Apply R1 schema
# =============================================================================
section "3. Applying R1 schema to lifeos_dev"
docker exec -i $DB_CONTAINER psql -U $DB_USER -d $DEV_DB < "$SCRIPT_DIR/lifeos_r1_full_schema.sql"
echo "OK: R1 schema applied"
# Verify table count
TABLE_COUNT=$(docker exec $DB_CONTAINER psql -U $DB_USER -d $DEV_DB -tc "
SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
" | tr -d ' ')
echo "Tables created: $TABLE_COUNT"
# =============================================================================
# 4. Run data migration (R0 -> R1)
# =============================================================================
section "4. Migrating data from lifeos_prod (R0) to lifeos_dev (R1)"
docker exec -i $DB_CONTAINER psql -U $DB_USER -d $DEV_DB < "$SCRIPT_DIR/lifeos_r0_to_r1_migration.sql"
echo "OK: Data migration complete"
# =============================================================================
# 5. Final verification
# =============================================================================
section "5. Final verification"
echo "R1 table row counts:"
docker exec $DB_CONTAINER psql -U $DB_USER -d $DEV_DB -c "
SELECT 'domains' as table_name, count(*) FROM domains UNION ALL
SELECT 'areas', count(*) FROM areas UNION ALL
SELECT 'projects', count(*) FROM projects UNION ALL
SELECT 'tasks', count(*) FROM tasks UNION ALL
SELECT 'notes', count(*) FROM notes UNION ALL
SELECT 'links', count(*) FROM links UNION ALL
SELECT 'daily_focus', count(*) FROM daily_focus UNION ALL
SELECT 'capture', count(*) FROM capture UNION ALL
SELECT 'context_types', count(*) FROM context_types UNION ALL
SELECT 'contacts', count(*) FROM contacts UNION ALL
SELECT 'meetings', count(*) FROM meetings UNION ALL
SELECT 'decisions', count(*) FROM decisions UNION ALL
SELECT 'releases', count(*) FROM releases UNION ALL
SELECT 'processes', count(*) FROM processes
ORDER BY table_name;
"
echo ""
echo "=============================================="
echo " DEV database setup complete."
echo " lifeos_dev has R1 schema + migrated R0 data."
echo " lifeos_prod R0 data is UNTOUCHED."
echo "=============================================="

View File

@@ -0,0 +1,118 @@
#!/bin/bash
# =============================================================================
# Life OS - PROD Database Setup
# Backs up lifeos_dev (R1) and restores to lifeos_prod
# Run AFTER DEV is fully tested and confirmed working
# Run on: defiant-01 as root
# =============================================================================
set -e
DB_CONTAINER="lifeos-db"
DB_USER="postgres"
DEV_DB="lifeos_dev"
PROD_DB="lifeos_prod"
BACKUP_DIR="/opt/lifeos/backups"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="$BACKUP_DIR/dev_to_prod_${TIMESTAMP}.sql"
section() {
echo ""
echo "=============================================="
echo " $1"
echo "=============================================="
}
# =============================================================================
# 1. Verify prerequisites
# =============================================================================
section "1. Verifying prerequisites"
if ! docker ps | grep -q "$DB_CONTAINER"; then
echo "ERROR: $DB_CONTAINER is not running"
exit 1
fi
echo "OK: $DB_CONTAINER is running"
mkdir -p "$BACKUP_DIR"
# =============================================================================
# 2. Backup current lifeos_prod (safety net)
# =============================================================================
section "2. Backing up current lifeos_prod (R0 safety copy)"
docker exec $DB_CONTAINER pg_dump -U $DB_USER $PROD_DB | gzip > "$BACKUP_DIR/prod_r0_backup_${TIMESTAMP}.sql.gz"
echo "OK: R0 prod backup saved to $BACKUP_DIR/prod_r0_backup_${TIMESTAMP}.sql.gz"
# =============================================================================
# 3. Backup lifeos_dev (source for PROD)
# =============================================================================
section "3. Backing up lifeos_dev (R1 source)"
docker exec $DB_CONTAINER pg_dump -U $DB_USER --clean --if-exists $DEV_DB > "$BACKUP_FILE"
echo "OK: DEV backup saved to $BACKUP_FILE"
# =============================================================================
# 4. Drop and recreate lifeos_prod with R1 data
# =============================================================================
section "4. Replacing lifeos_prod with lifeos_dev contents"
echo "WARNING: This will destroy the current lifeos_prod database."
echo "R0 backup is at: $BACKUP_DIR/prod_r0_backup_${TIMESTAMP}.sql.gz"
read -p "Continue? (yes/no): " CONFIRM
if [ "$CONFIRM" != "yes" ]; then
echo "Aborted."
exit 0
fi
# Drop and recreate prod database
docker exec $DB_CONTAINER psql -U $DB_USER -c "
SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$PROD_DB' AND pid <> pg_backend_pid();
"
docker exec $DB_CONTAINER psql -U $DB_USER -c "DROP DATABASE IF EXISTS $PROD_DB;"
docker exec $DB_CONTAINER psql -U $DB_USER -c "CREATE DATABASE $PROD_DB;"
# Restore DEV backup into PROD
docker exec -i $DB_CONTAINER psql -U $DB_USER -d $PROD_DB < "$BACKUP_FILE"
echo "OK: lifeos_prod now contains R1 schema + data from DEV"
# =============================================================================
# 5. Verify
# =============================================================================
section "5. Verification"
echo "PROD table row counts:"
docker exec $DB_CONTAINER psql -U $DB_USER -d $PROD_DB -c "
SELECT 'domains' as table_name, count(*) FROM domains UNION ALL
SELECT 'areas', count(*) FROM areas UNION ALL
SELECT 'projects', count(*) FROM projects UNION ALL
SELECT 'tasks', count(*) FROM tasks UNION ALL
SELECT 'notes', count(*) FROM notes UNION ALL
SELECT 'links', count(*) FROM links UNION ALL
SELECT 'daily_focus', count(*) FROM daily_focus UNION ALL
SELECT 'capture', count(*) FROM capture UNION ALL
SELECT 'context_types', count(*) FROM context_types
ORDER BY table_name;
"
# =============================================================================
# 6. Setup automated daily backup cron
# =============================================================================
section "6. Setting up automated daily backups"
CRON_LINE="0 3 * * * docker exec $DB_CONTAINER pg_dump -U $DB_USER $PROD_DB | gzip > $BACKUP_DIR/prod_\$(date +\\%Y\\%m\\%d).sql.gz && find $BACKUP_DIR -name 'prod_*.sql.gz' -mtime +30 -delete"
if crontab -l 2>/dev/null | grep -q "lifeos_prod"; then
echo "Backup cron already exists, skipping."
else
(crontab -l 2>/dev/null; echo "$CRON_LINE") | crontab -
echo "OK: Daily backup cron installed (3am, 30-day retention)"
fi
echo ""
echo "=============================================="
echo " PROD setup complete."
echo " lifeos_prod now has R1 schema + data."
echo " R0 backup: $BACKUP_DIR/prod_r0_backup_${TIMESTAMP}.sql.gz"
echo " Daily backups configured."
echo "=============================================="

6
pytest.ini Normal file
View File

@@ -0,0 +1,6 @@
[pytest]
asyncio_mode = auto
asyncio_default_fixture_loop_scope = session
asyncio_default_test_loop_scope = session
testpaths = tests
addopts = -v --tb=short

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

129
routers/admin.py Normal file
View File

@@ -0,0 +1,129 @@
"""Admin Trash: view, restore, and permanently delete soft-deleted items."""
from fastapi import APIRouter, Request, Depends, Form
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="/admin/trash", tags=["admin"])
templates = Jinja2Templates(directory="templates")
# Entity configs for trash view: (table, display_name, name_column, detail_url_pattern)
TRASH_ENTITIES = [
{"table": "tasks", "label": "Tasks", "name_col": "title", "url": "/tasks/{id}"},
{"table": "projects", "label": "Projects", "name_col": "name", "url": "/projects/{id}"},
{"table": "notes", "label": "Notes", "name_col": "title", "url": "/notes/{id}"},
{"table": "links", "label": "Links", "name_col": "label", "url": "/links"},
{"table": "contacts", "label": "Contacts", "name_col": "first_name", "url": "/contacts/{id}"},
{"table": "domains", "label": "Domains", "name_col": "name", "url": "/domains"},
{"table": "areas", "label": "Areas", "name_col": "name", "url": "/areas"},
{"table": "lists", "label": "Lists", "name_col": "name", "url": "/lists/{id}"},
{"table": "meetings", "label": "Meetings", "name_col": "title", "url": "/meetings/{id}"},
{"table": "decisions", "label": "Decisions", "name_col": "title", "url": "/decisions/{id}"},
{"table": "files", "label": "Files", "name_col": "original_filename", "url": "/files"},
{"table": "link_folders", "label": "Link Folders", "name_col": "name", "url": "/weblinks"},
{"table": "appointments", "label": "Appointments", "name_col": "title", "url": "/appointments/{id}"},
{"table": "capture", "label": "Capture", "name_col": "raw_text", "url": "/capture"},
{"table": "daily_focus", "label": "Focus Items", "name_col": "id", "url": "/focus"},
{"table": "processes", "label": "Processes", "name_col": "name", "url": "/processes/{id}"},
{"table": "process_runs", "label": "Process Runs", "name_col": "title", "url": "/processes/runs/{id}"},
{"table": "time_budgets", "label": "Time Budgets", "name_col": "id", "url": "/time-budgets"},
]
@router.get("/")
async def trash_view(
request: Request,
entity_type: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
deleted_items = []
entity_counts = {}
for entity in TRASH_ENTITIES:
try:
# Count deleted items per type
result = await db.execute(text(
f"SELECT count(*) FROM {entity['table']} WHERE is_deleted = true"
))
count = result.scalar() or 0
entity_counts[entity["table"]] = count
# Load items for selected type (or all if none selected)
if count > 0 and (entity_type is None or entity_type == entity["table"]):
result = await db.execute(text(f"""
SELECT id, {entity['name_col']} as display_name, deleted_at, created_at
FROM {entity['table']}
WHERE is_deleted = true
ORDER BY deleted_at DESC
LIMIT 50
"""))
rows = [dict(r._mapping) for r in result]
for row in rows:
deleted_items.append({
"id": str(row["id"]),
"name": str(row.get("display_name") or row["id"])[:100],
"table": entity["table"],
"type_label": entity["label"],
"deleted_at": row.get("deleted_at"),
"created_at": row.get("created_at"),
})
except Exception:
continue
total_deleted = sum(entity_counts.values())
return templates.TemplateResponse("trash.html", {
"request": request, "sidebar": sidebar,
"deleted_items": deleted_items,
"entity_counts": entity_counts,
"trash_entities": TRASH_ENTITIES,
"current_type": entity_type or "",
"total_deleted": total_deleted,
"page_title": "Trash", "active_nav": "trash",
})
@router.post("/{table}/{item_id}/restore")
async def restore_item(table: str, item_id: str, request: Request, db: AsyncSession = Depends(get_db)):
# Validate table name against known entities
valid_tables = {e["table"] for e in TRASH_ENTITIES}
if table not in valid_tables:
return RedirectResponse(url="/admin/trash", status_code=303)
repo = BaseRepository(table, db)
await repo.restore(item_id)
referer = request.headers.get("referer", "/admin/trash")
return RedirectResponse(url=referer, status_code=303)
@router.post("/{table}/{item_id}/permanent-delete")
async def permanent_delete_item(table: str, item_id: str, request: Request, db: AsyncSession = Depends(get_db)):
valid_tables = {e["table"] for e in TRASH_ENTITIES}
if table not in valid_tables:
return RedirectResponse(url="/admin/trash", status_code=303)
repo = BaseRepository(table, db)
await repo.permanent_delete(item_id)
referer = request.headers.get("referer", "/admin/trash")
return RedirectResponse(url=referer, status_code=303)
@router.post("/empty")
async def empty_trash(request: Request, db: AsyncSession = Depends(get_db)):
"""Permanently delete ALL soft-deleted items across all tables."""
for entity in TRASH_ENTITIES:
try:
await db.execute(text(
f"DELETE FROM {entity['table']} WHERE is_deleted = true"
))
except Exception:
continue
return RedirectResponse(url="/admin/trash", status_code=303)

302
routers/appointments.py Normal file
View File

@@ -0,0 +1,302 @@
"""Appointments CRUD: scheduling with contacts, recurrence, all-day support."""
from fastapi import APIRouter, Request, Depends, Form, Query
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
from core.database import get_db
from core.base_repository import BaseRepository
from core.sidebar import get_sidebar_data
router = APIRouter(prefix="/appointments", tags=["appointments"])
templates = Jinja2Templates(directory="templates")
@router.get("/")
async def list_appointments(
request: Request,
status: Optional[str] = None,
timeframe: Optional[str] = "upcoming",
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
repo = BaseRepository("appointments", db)
# Build filter and sort based on timeframe
if timeframe == "past":
result = await db.execute(text("""
SELECT * FROM appointments
WHERE is_deleted = false AND start_at < now()
ORDER BY start_at DESC
LIMIT 100
"""))
elif timeframe == "all":
result = await db.execute(text("""
SELECT * FROM appointments
WHERE is_deleted = false
ORDER BY start_at DESC
LIMIT 200
"""))
else:
# upcoming (default)
result = await db.execute(text("""
SELECT * FROM appointments
WHERE is_deleted = false AND start_at >= CURRENT_DATE
ORDER BY start_at ASC
LIMIT 100
"""))
appointments = [dict(r._mapping) for r in result]
# Get contact counts per appointment
if appointments:
ids = [str(a["id"]) for a in appointments]
placeholders = ", ".join(f"'{i}'" for i in ids)
contact_result = await db.execute(text(f"""
SELECT appointment_id, count(*) as cnt
FROM contact_appointments
WHERE appointment_id IN ({placeholders})
GROUP BY appointment_id
"""))
contact_counts = {str(r._mapping["appointment_id"]): r._mapping["cnt"] for r in contact_result}
for a in appointments:
a["contact_count"] = contact_counts.get(str(a["id"]), 0)
count = len(appointments)
return templates.TemplateResponse("appointments.html", {
"request": request,
"sidebar": sidebar,
"appointments": appointments,
"count": count,
"timeframe": timeframe or "upcoming",
"page_title": "Appointments",
"active_nav": "appointments",
})
@router.get("/new")
async def new_appointment(request: Request, db: AsyncSession = Depends(get_db)):
sidebar = await get_sidebar_data(db)
# Load contacts for attendee selection
result = await db.execute(text("""
SELECT id, first_name, last_name, company
FROM contacts WHERE is_deleted = false
ORDER BY first_name, last_name
"""))
contacts = [dict(r._mapping) for r in result]
return templates.TemplateResponse("appointment_form.html", {
"request": request,
"sidebar": sidebar,
"appointment": None,
"contacts": contacts,
"selected_contacts": [],
"page_title": "New Appointment",
"active_nav": "appointments",
})
@router.post("/create")
async def create_appointment(
request: Request,
title: str = Form(...),
description: Optional[str] = Form(None),
location: Optional[str] = Form(None),
start_date: str = Form(...),
start_time: Optional[str] = Form(None),
end_date: Optional[str] = Form(None),
end_time: Optional[str] = Form(None),
all_day: Optional[str] = Form(None),
recurrence: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
contact_ids: Optional[list[str]] = Form(None),
db: AsyncSession = Depends(get_db),
):
is_all_day = all_day == "on"
# Build start_at
if is_all_day:
start_at = f"{start_date}T00:00:00"
else:
start_at = f"{start_date}T{start_time or '09:00'}:00"
# Build end_at
end_at = None
if end_date:
if is_all_day:
end_at = f"{end_date}T23:59:59"
else:
end_at = f"{end_date}T{end_time or '10:00'}:00"
data = {
"title": title,
"description": description or None,
"location": location or None,
"start_at": start_at,
"end_at": end_at,
"all_day": is_all_day,
"recurrence": recurrence or None,
"tags": [t.strip() for t in tags.split(",") if t.strip()] if tags else None,
}
repo = BaseRepository("appointments", db)
appointment = await repo.create(data)
# Add contact associations
if contact_ids:
for cid in contact_ids:
if cid:
await db.execute(text("""
INSERT INTO contact_appointments (contact_id, appointment_id)
VALUES (:cid, :aid)
ON CONFLICT DO NOTHING
"""), {"cid": cid, "aid": str(appointment["id"])})
return RedirectResponse(url=f"/appointments/{appointment['id']}", status_code=303)
@router.get("/{appointment_id}")
async def appointment_detail(
request: Request,
appointment_id: str,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
repo = BaseRepository("appointments", db)
appointment = await repo.get(appointment_id)
if not appointment:
return RedirectResponse(url="/appointments", status_code=303)
# Get associated contacts
result = await db.execute(text("""
SELECT c.id, c.first_name, c.last_name, c.company, c.email, ca.role
FROM contact_appointments ca
JOIN contacts c ON ca.contact_id = c.id
WHERE ca.appointment_id = :aid AND c.is_deleted = false
ORDER BY c.first_name, c.last_name
"""), {"aid": appointment_id})
contacts = [dict(r._mapping) for r in result]
return templates.TemplateResponse("appointment_detail.html", {
"request": request,
"sidebar": sidebar,
"appointment": appointment,
"contacts": contacts,
"page_title": appointment["title"],
"active_nav": "appointments",
})
@router.get("/{appointment_id}/edit")
async def edit_appointment_form(
request: Request,
appointment_id: str,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
repo = BaseRepository("appointments", db)
appointment = await repo.get(appointment_id)
if not appointment:
return RedirectResponse(url="/appointments", status_code=303)
# All contacts
result = await db.execute(text("""
SELECT id, first_name, last_name, company
FROM contacts WHERE is_deleted = false
ORDER BY first_name, last_name
"""))
contacts = [dict(r._mapping) for r in result]
# Currently linked contacts
result = await db.execute(text("""
SELECT contact_id FROM contact_appointments WHERE appointment_id = :aid
"""), {"aid": appointment_id})
selected_contacts = [str(r._mapping["contact_id"]) for r in result]
return templates.TemplateResponse("appointment_form.html", {
"request": request,
"sidebar": sidebar,
"appointment": appointment,
"contacts": contacts,
"selected_contacts": selected_contacts,
"page_title": f"Edit: {appointment['title']}",
"active_nav": "appointments",
})
@router.post("/{appointment_id}/edit")
async def update_appointment(
request: Request,
appointment_id: str,
title: str = Form(...),
description: Optional[str] = Form(None),
location: Optional[str] = Form(None),
start_date: str = Form(...),
start_time: Optional[str] = Form(None),
end_date: Optional[str] = Form(None),
end_time: Optional[str] = Form(None),
all_day: Optional[str] = Form(None),
recurrence: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
contact_ids: Optional[list[str]] = Form(None),
db: AsyncSession = Depends(get_db),
):
is_all_day = all_day == "on"
if is_all_day:
start_at = f"{start_date}T00:00:00"
else:
start_at = f"{start_date}T{start_time or '09:00'}:00"
end_at = None
if end_date:
if is_all_day:
end_at = f"{end_date}T23:59:59"
else:
end_at = f"{end_date}T{end_time or '10:00'}:00"
data = {
"title": title,
"description": description or None,
"location": location or None,
"start_at": start_at,
"end_at": end_at,
"all_day": is_all_day,
"recurrence": recurrence or None,
"tags": [t.strip() for t in tags.split(",") if t.strip()] if tags else None,
}
repo = BaseRepository("appointments", db)
await repo.update(appointment_id, data)
# Rebuild contact associations
await db.execute(text("DELETE FROM contact_appointments WHERE appointment_id = :aid"), {"aid": appointment_id})
if contact_ids:
for cid in contact_ids:
if cid:
await db.execute(text("""
INSERT INTO contact_appointments (contact_id, appointment_id)
VALUES (:cid, :aid)
ON CONFLICT DO NOTHING
"""), {"cid": cid, "aid": appointment_id})
return RedirectResponse(url=f"/appointments/{appointment_id}", status_code=303)
@router.post("/{appointment_id}/delete")
async def delete_appointment(
request: Request,
appointment_id: str,
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("appointments", db)
await repo.soft_delete(appointment_id)
return RedirectResponse(url="/appointments", status_code=303)

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)

138
routers/calendar.py Normal file
View File

@@ -0,0 +1,138 @@
"""Calendar: unified read-only month view of appointments, meetings, and tasks."""
from fastapi import APIRouter, Request, Depends
from fastapi.templating import Jinja2Templates
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from datetime import date, timedelta
import calendar
from core.database import get_db
from core.sidebar import get_sidebar_data
router = APIRouter(prefix="/calendar", tags=["calendar"])
templates = Jinja2Templates(directory="templates")
@router.get("/")
async def calendar_view(
request: Request,
year: int = None,
month: int = None,
db: AsyncSession = Depends(get_db),
):
today = date.today()
year = year or today.year
month = month or today.month
# Clamp to valid range
if month < 1 or month > 12:
month = today.month
if year < 2000 or year > 2100:
year = today.year
first_day = date(year, month, 1)
last_day = date(year, month, calendar.monthrange(year, month)[1])
# Prev/next month
prev_month = first_day - timedelta(days=1)
next_month = last_day + timedelta(days=1)
sidebar = await get_sidebar_data(db)
# Appointments in this month (by start_at)
appt_result = await db.execute(text("""
SELECT id, title, start_at, end_at, all_day, location
FROM appointments
WHERE is_deleted = false
AND start_at::date >= :first AND start_at::date <= :last
ORDER BY start_at
"""), {"first": first_day, "last": last_day})
appointments = [dict(r._mapping) for r in appt_result]
# Meetings in this month (by meeting_date)
meet_result = await db.execute(text("""
SELECT id, title, meeting_date, start_at, location, status
FROM meetings
WHERE is_deleted = false
AND meeting_date >= :first AND meeting_date <= :last
ORDER BY meeting_date, start_at
"""), {"first": first_day, "last": last_day})
meetings = [dict(r._mapping) for r in meet_result]
# Tasks with due dates in this month (open/in_progress only)
task_result = await db.execute(text("""
SELECT t.id, t.title, t.due_date, t.priority, t.status,
p.name as project_name
FROM tasks t
LEFT JOIN projects p ON t.project_id = p.id
WHERE t.is_deleted = false
AND t.status IN ('open', 'in_progress')
AND t.due_date >= :first AND t.due_date <= :last
ORDER BY t.due_date, t.priority
"""), {"first": first_day, "last": last_day})
tasks = [dict(r._mapping) for r in task_result]
# Build day-indexed event map
days_map = {}
for a in appointments:
day = a["start_at"].date().day if a["start_at"] else None
if day:
days_map.setdefault(day, []).append({
"type": "appointment",
"id": a["id"],
"title": a["title"],
"time": None if a["all_day"] else a["start_at"].strftime("%-I:%M %p"),
"url": f"/appointments/{a['id']}",
"all_day": a["all_day"],
})
for m in meetings:
day = m["meeting_date"].day if m["meeting_date"] else None
if day:
time_str = m["start_at"].strftime("%-I:%M %p") if m["start_at"] else None
days_map.setdefault(day, []).append({
"type": "meeting",
"id": m["id"],
"title": m["title"],
"time": time_str,
"url": f"/meetings/{m['id']}",
"all_day": False,
})
for t in tasks:
day = t["due_date"].day if t["due_date"] else None
if day:
days_map.setdefault(day, []).append({
"type": "task",
"id": t["id"],
"title": t["title"],
"time": None,
"url": f"/tasks/{t['id']}",
"priority": t["priority"],
"project_name": t.get("project_name"),
"all_day": False,
})
# Build calendar grid (weeks of days)
# Monday=0, Sunday=6
cal = calendar.Calendar(firstweekday=6) # Sunday start
weeks = cal.monthdayscalendar(year, month)
return templates.TemplateResponse("calendar.html", {
"request": request,
"sidebar": sidebar,
"year": year,
"month": month,
"month_name": calendar.month_name[month],
"weeks": weeks,
"days_map": days_map,
"today": today,
"first_day": first_day,
"prev_year": prev_month.year,
"prev_month": prev_month.month,
"next_year": next_month.year,
"next_month": next_month.month,
"page_title": f"Calendar - {calendar.month_name[month]} {year}",
"active_nav": "calendar",
})

361
routers/capture.py Normal file
View File

@@ -0,0 +1,361 @@
"""Capture: quick text capture queue with conversion to any entity type."""
import re
from uuid import uuid4
from typing import Optional
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 core.database import get_db
from core.base_repository import BaseRepository
from core.sidebar import get_sidebar_data
from routers.weblinks import get_default_folder_id
router = APIRouter(prefix="/capture", tags=["capture"])
templates = Jinja2Templates(directory="templates")
CONVERT_TYPES = {
"task": "Task",
"note": "Note",
"project": "Project",
"list_item": "List Item",
"contact": "Contact",
"decision": "Decision",
"link": "Link",
}
@router.get("/")
async def list_capture(request: Request, show: str = "inbox", db: AsyncSession = Depends(get_db)):
sidebar = await get_sidebar_data(db)
if show == "processed":
where = "is_deleted = false AND processed = true"
elif show == "all":
where = "is_deleted = false"
else: # inbox
where = "is_deleted = false AND processed = false"
result = await db.execute(text(f"""
SELECT * FROM capture WHERE {where} ORDER BY created_at DESC
"""))
items = [dict(r._mapping) for r in result]
# Mark first item per batch for batch-undo display
batches = {}
for item in items:
bid = item.get("import_batch_id")
if bid:
bid_str = str(bid)
if bid_str not in batches:
batches[bid_str] = 0
item["_batch_first"] = True
else:
item["_batch_first"] = False
batches[bid_str] += 1
else:
item["_batch_first"] = False
# Get lists for list_item conversion
result = await db.execute(text(
"SELECT id, name FROM lists WHERE is_deleted = false ORDER BY name"
))
all_lists = [dict(r._mapping) for r in result]
return templates.TemplateResponse("capture.html", {
"request": request, "sidebar": sidebar, "items": items,
"show": show, "batches": batches, "all_lists": all_lists,
"convert_types": CONVERT_TYPES,
"page_title": "Capture", "active_nav": "capture",
})
@router.post("/add")
async def add_capture(
request: Request,
raw_text: str = Form(...),
redirect_to: Optional[str] = Form(None),
area_id: Optional[str] = Form(None),
project_id: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("capture", db)
lines = [l.strip() for l in raw_text.strip().split("\n") if l.strip()]
batch_id = str(uuid4()) if len(lines) > 1 else None
for line in lines:
data = {"raw_text": line, "processed": False}
if batch_id:
data["import_batch_id"] = batch_id
if area_id and area_id.strip():
data["area_id"] = area_id
if project_id and project_id.strip():
data["project_id"] = project_id
await repo.create(data)
url = redirect_to if redirect_to and redirect_to.startswith("/") else "/capture"
return RedirectResponse(url=url, status_code=303)
# ---- Batch undo (must be before /{capture_id} routes) ----
@router.post("/batch/{batch_id}/undo")
async def batch_undo(batch_id: str, db: AsyncSession = Depends(get_db)):
"""Delete all items from a batch."""
await db.execute(text("""
UPDATE capture SET is_deleted = true, deleted_at = now(), updated_at = now()
WHERE import_batch_id = :bid AND is_deleted = false
"""), {"bid": batch_id})
await db.commit()
return RedirectResponse(url="/capture", status_code=303)
# ---- Conversion form page ----
@router.get("/{capture_id}/convert/{convert_type}")
async def convert_form(
capture_id: str, convert_type: str,
request: Request, db: AsyncSession = Depends(get_db),
):
"""Show conversion form for a specific capture item."""
repo = BaseRepository("capture", db)
item = await repo.get(capture_id)
if not item or convert_type not in CONVERT_TYPES:
return RedirectResponse(url="/capture", status_code=303)
sidebar = await get_sidebar_data(db)
result = await db.execute(text(
"SELECT id, name FROM lists WHERE is_deleted = false ORDER BY name"
))
all_lists = [dict(r._mapping) for r in result]
# Parse name for contact pre-fill
parts = item["raw_text"].strip().split(None, 1)
first_name = parts[0] if parts else item["raw_text"]
last_name = parts[1] if len(parts) > 1 else ""
# Extract URL for weblink pre-fill
url_match = re.search(r'https?://\S+', item["raw_text"])
prefill_url = url_match.group(0) if url_match else ""
prefill_label = item["raw_text"].replace(prefill_url, "").strip() if url_match else item["raw_text"]
return templates.TemplateResponse("capture_convert.html", {
"request": request, "sidebar": sidebar,
"item": item, "convert_type": convert_type,
"type_label": CONVERT_TYPES[convert_type],
"all_lists": all_lists,
"first_name": first_name, "last_name": last_name,
"prefill_url": prefill_url, "prefill_label": prefill_label or item["raw_text"],
"page_title": f"Convert to {CONVERT_TYPES[convert_type]}",
"active_nav": "capture",
})
# ---- Conversion handlers ----
@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),
priority: int = Form(3),
title: 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": (title or item["raw_text"]).strip(),
"domain_id": domain_id, "status": "open", "priority": priority,
}
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=f"/tasks/{task['id']}", status_code=303)
@router.post("/{capture_id}/to-note")
async def convert_to_note(
capture_id: str, request: Request,
title: Optional[str] = Form(None),
domain_id: Optional[str] = Form(None),
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)
note_repo = BaseRepository("notes", db)
raw = item["raw_text"]
data = {"title": (title or raw[:100]).strip(), "body": raw}
if domain_id and domain_id.strip():
data["domain_id"] = domain_id
if project_id and project_id.strip():
data["project_id"] = project_id
note = await note_repo.create(data)
await capture_repo.update(capture_id, {
"processed": True, "converted_to_type": "note",
"converted_to_id": str(note["id"]),
})
return RedirectResponse(url=f"/notes/{note['id']}", status_code=303)
@router.post("/{capture_id}/to-project")
async def convert_to_project(
capture_id: str, request: Request,
domain_id: str = Form(...),
name: 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)
project_repo = BaseRepository("projects", db)
data = {"name": (name or item["raw_text"]).strip(), "domain_id": domain_id, "status": "active"}
project = await project_repo.create(data)
await capture_repo.update(capture_id, {
"processed": True, "converted_to_type": "project",
"converted_to_id": str(project["id"]),
})
return RedirectResponse(url=f"/projects/{project['id']}", status_code=303)
@router.post("/{capture_id}/to-list_item")
async def convert_to_list_item(
capture_id: str, request: Request,
list_id: str = Form(...),
content: 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)
li_repo = BaseRepository("list_items", db)
data = {"list_id": list_id, "content": (content or item["raw_text"]).strip()}
li = await li_repo.create(data)
await capture_repo.update(capture_id, {
"processed": True, "converted_to_type": "list_item",
"converted_to_id": str(li["id"]), "list_id": list_id,
})
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)
@router.post("/{capture_id}/to-contact")
async def convert_to_contact(
capture_id: str, request: Request,
first_name: str = Form(...),
last_name: 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)
contact_repo = BaseRepository("contacts", db)
data = {"first_name": first_name.strip()}
if last_name and last_name.strip():
data["last_name"] = last_name.strip()
contact = await contact_repo.create(data)
await capture_repo.update(capture_id, {
"processed": True, "converted_to_type": "contact",
"converted_to_id": str(contact["id"]),
})
return RedirectResponse(url=f"/contacts/{contact['id']}", status_code=303)
@router.post("/{capture_id}/to-decision")
async def convert_to_decision(
capture_id: str, request: Request,
title: 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)
decision_repo = BaseRepository("decisions", db)
data = {"title": (title or item["raw_text"]).strip(), "status": "proposed", "impact": "medium"}
decision = await decision_repo.create(data)
await capture_repo.update(capture_id, {
"processed": True, "converted_to_type": "decision",
"converted_to_id": str(decision["id"]),
})
return RedirectResponse(url=f"/decisions/{decision['id']}", status_code=303)
@router.post("/{capture_id}/to-link")
async def convert_to_link(
capture_id: str, request: Request,
label: Optional[str] = Form(None),
url: 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)
link_repo = BaseRepository("links", db)
raw = item["raw_text"]
url_match = re.search(r'https?://\S+', raw)
link_url = (url.strip() if url and url.strip() else None) or (url_match.group(0) if url_match else raw)
link_label = (label.strip() if label and label.strip() else None) or (raw.replace(link_url, "").strip() if url_match else raw[:100])
if not link_label:
link_label = link_url
data = {"label": link_label, "url": link_url}
link = await link_repo.create(data)
# Assign to Default folder
default_fid = await get_default_folder_id(db)
await db.execute(text("""
INSERT INTO folder_links (folder_id, link_id)
VALUES (:fid, :lid) ON CONFLICT DO NOTHING
"""), {"fid": default_fid, "lid": link["id"]})
await capture_repo.update(capture_id, {
"processed": True, "converted_to_type": "link",
"converted_to_id": str(link["id"]),
})
return RedirectResponse(url="/links", 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, "converted_to_type": "dismissed"})
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)

218
routers/decisions.py Normal file
View File

@@ -0,0 +1,218 @@
"""Decisions: knowledge base of decisions with rationale, status, and supersession."""
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="/decisions", tags=["decisions"])
templates = Jinja2Templates(directory="templates")
@router.get("/")
async def list_decisions(
request: Request,
status: Optional[str] = None,
impact: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
where_clauses = ["d.is_deleted = false"]
params = {}
if status:
where_clauses.append("d.status = :status")
params["status"] = status
if impact:
where_clauses.append("d.impact = :impact")
params["impact"] = impact
where_sql = " AND ".join(where_clauses)
result = await db.execute(text(f"""
SELECT d.*, m.title as meeting_title
FROM decisions d
LEFT JOIN meetings m ON d.meeting_id = m.id
WHERE {where_sql}
ORDER BY d.created_at DESC
"""), params)
items = [dict(r._mapping) for r in result]
return templates.TemplateResponse("decisions.html", {
"request": request, "sidebar": sidebar, "items": items,
"current_status": status or "",
"current_impact": impact or "",
"page_title": "Decisions", "active_nav": "decisions",
})
@router.get("/create")
async def create_form(
request: Request,
meeting_id: Optional[str] = None,
task_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
# Meetings for linking
result = await db.execute(text("""
SELECT id, title, meeting_date FROM meetings
WHERE is_deleted = false ORDER BY meeting_date DESC LIMIT 50
"""))
meetings = [dict(r._mapping) for r in result]
return templates.TemplateResponse("decision_form.html", {
"request": request, "sidebar": sidebar,
"meetings": meetings,
"page_title": "New Decision", "active_nav": "decisions",
"item": None,
"prefill_meeting_id": meeting_id or "",
"prefill_task_id": task_id or "",
})
@router.post("/create")
async def create_decision(
request: Request,
title: str = Form(...),
rationale: Optional[str] = Form(None),
status: str = Form("proposed"),
impact: str = Form("medium"),
decided_at: Optional[str] = Form(None),
meeting_id: Optional[str] = Form(None),
task_id: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("decisions", db)
data = {
"title": title, "status": status, "impact": impact,
"rationale": rationale,
}
if decided_at and decided_at.strip():
data["decided_at"] = decided_at
if meeting_id and meeting_id.strip():
data["meeting_id"] = meeting_id
if task_id and task_id.strip():
data["task_id"] = task_id
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
decision = await repo.create(data)
if task_id and task_id.strip():
return RedirectResponse(url=f"/tasks/{task_id}?tab=decisions", status_code=303)
return RedirectResponse(url=f"/decisions/{decision['id']}", status_code=303)
@router.get("/{decision_id}")
async def decision_detail(decision_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("decisions", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(decision_id)
if not item:
return RedirectResponse(url="/decisions", status_code=303)
# Meeting info
meeting = None
if item.get("meeting_id"):
result = await db.execute(text(
"SELECT id, title, meeting_date FROM meetings WHERE id = :id"
), {"id": str(item["meeting_id"])})
row = result.first()
meeting = dict(row._mapping) if row else None
# Superseded by
superseded_by = None
if item.get("superseded_by_id"):
result = await db.execute(text(
"SELECT id, title FROM decisions WHERE id = :id"
), {"id": str(item["superseded_by_id"])})
row = result.first()
superseded_by = dict(row._mapping) if row else None
# Decisions that this one supersedes
result = await db.execute(text("""
SELECT id, title FROM decisions
WHERE superseded_by_id = :did AND is_deleted = false
"""), {"did": decision_id})
supersedes = [dict(r._mapping) for r in result]
return templates.TemplateResponse("decision_detail.html", {
"request": request, "sidebar": sidebar, "item": item,
"meeting": meeting, "superseded_by": superseded_by,
"supersedes": supersedes,
"page_title": item["title"], "active_nav": "decisions",
})
@router.get("/{decision_id}/edit")
async def edit_form(decision_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("decisions", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(decision_id)
if not item:
return RedirectResponse(url="/decisions", status_code=303)
result = await db.execute(text("""
SELECT id, title, meeting_date FROM meetings
WHERE is_deleted = false ORDER BY meeting_date DESC LIMIT 50
"""))
meetings = [dict(r._mapping) for r in result]
# Other decisions for supersession
result = await db.execute(text("""
SELECT id, title FROM decisions
WHERE is_deleted = false AND id != :did ORDER BY created_at DESC LIMIT 50
"""), {"did": decision_id})
other_decisions = [dict(r._mapping) for r in result]
return templates.TemplateResponse("decision_form.html", {
"request": request, "sidebar": sidebar,
"meetings": meetings, "other_decisions": other_decisions,
"page_title": "Edit Decision", "active_nav": "decisions",
"item": item,
"prefill_meeting_id": "",
})
@router.post("/{decision_id}/edit")
async def update_decision(
decision_id: str,
title: str = Form(...),
rationale: Optional[str] = Form(None),
status: str = Form("proposed"),
impact: str = Form("medium"),
decided_at: Optional[str] = Form(None),
meeting_id: Optional[str] = Form(None),
superseded_by_id: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("decisions", db)
data = {
"title": title, "status": status, "impact": impact,
"rationale": rationale if rationale and rationale.strip() else None,
"decided_at": decided_at if decided_at and decided_at.strip() else None,
"meeting_id": meeting_id if meeting_id and meeting_id.strip() else None,
"superseded_by_id": superseded_by_id if superseded_by_id and superseded_by_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(decision_id, data)
return RedirectResponse(url=f"/decisions/{decision_id}", status_code=303)
@router.post("/{decision_id}/delete")
async def delete_decision(decision_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("decisions", db)
await repo.soft_delete(decision_id)
return RedirectResponse(url="/decisions", 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)

120
routers/eisenhower.py Normal file
View File

@@ -0,0 +1,120 @@
"""Eisenhower Matrix: read-only 2x2 priority/urgency grid of open tasks."""
from fastapi import APIRouter, Request, Depends
from fastapi.templating import Jinja2Templates
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="/eisenhower", tags=["eisenhower"])
templates = Jinja2Templates(directory="templates")
@router.get("/")
async def eisenhower_matrix(
request: Request,
domain_id: Optional[str] = None,
project_id: Optional[str] = None,
status: Optional[str] = None,
context: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
where_clauses = [
"t.is_deleted = false",
"t.status IN ('open', 'in_progress', 'blocked')",
]
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 context:
where_clauses.append("t.context = :context")
params["context"] = context
where_sql = " AND ".join(where_clauses)
result = await db.execute(text(f"""
SELECT t.id, t.title, t.priority, t.status, t.due_date,
t.context, t.estimated_minutes,
p.name as project_name,
d.name as domain_name, d.color as domain_color
FROM tasks t
LEFT JOIN projects p ON t.project_id = p.id
LEFT JOIN domains d ON t.domain_id = d.id
WHERE {where_sql}
ORDER BY t.priority, t.due_date NULLS LAST, t.title
"""), params)
tasks = [dict(r._mapping) for r in result]
# Classify into quadrants
from datetime import date, timedelta
today = date.today()
urgent_cutoff = today + timedelta(days=7)
quadrants = {
"do_first": [],
"schedule": [],
"delegate": [],
"eliminate": [],
}
for t in tasks:
important = t["priority"] in (1, 2)
urgent = (
t["due_date"] is not None
and t["due_date"] <= urgent_cutoff
)
if important and urgent:
quadrants["do_first"].append(t)
elif important and not urgent:
quadrants["schedule"].append(t)
elif not important and urgent:
quadrants["delegate"].append(t)
else:
quadrants["eliminate"].append(t)
counts = {k: len(v) for k, v in quadrants.items()}
total = sum(counts.values())
# 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("eisenhower.html", {
"request": request,
"sidebar": sidebar,
"quadrants": quadrants,
"counts": counts,
"total": total,
"today": today,
"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_context": context or "",
"page_title": "Eisenhower Matrix",
"active_nav": "eisenhower",
})

398
routers/files.py Normal file
View File

@@ -0,0 +1,398 @@
"""Files: upload, download, list, preview, folder-aware storage, and WebDAV sync."""
import os
import mimetypes
from pathlib import Path
from fastapi import APIRouter, Request, Form, Depends, UploadFile, File as FastAPIFile
from fastapi.templating import Jinja2Templates
from fastapi.responses import RedirectResponse, FileResponse, HTMLResponse
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="/files", tags=["files"])
templates = Jinja2Templates(directory="templates")
FILE_STORAGE_PATH = os.getenv("FILE_STORAGE_PATH", "/opt/lifeos/webdav")
# Ensure storage dir exists
Path(FILE_STORAGE_PATH).mkdir(parents=True, exist_ok=True)
# MIME types that can be previewed inline
PREVIEWABLE = {
"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml",
"application/pdf", "text/plain", "text/html", "text/csv",
}
# Files to skip during sync
SKIP_FILES = {".DS_Store", "Thumbs.db", ".gitkeep", "desktop.ini"}
def _resolve_path(item):
"""Resolve a DB record's relative storage_path to an absolute path."""
return os.path.join(FILE_STORAGE_PATH, item["storage_path"])
def get_folders():
"""Walk FILE_STORAGE_PATH and return sorted list of relative folder paths."""
folders = []
for dirpath, dirnames, _filenames in os.walk(FILE_STORAGE_PATH):
# Skip hidden directories
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
rel = os.path.relpath(dirpath, FILE_STORAGE_PATH)
if rel != ".":
folders.append(rel)
return sorted(folders)
def resolve_collision(folder_abs, filename):
"""If filename exists in folder_abs, return name (2).ext, name (3).ext, etc."""
target = os.path.join(folder_abs, filename)
if not os.path.exists(target):
return filename
name, ext = os.path.splitext(filename)
counter = 2
while True:
candidate = f"{name} ({counter}){ext}"
if not os.path.exists(os.path.join(folder_abs, candidate)):
return candidate
counter += 1
async def sync_files(db: AsyncSession):
"""Sync filesystem state with the database.
- Files on disk not in DB → create record
- Active DB records with missing files → soft-delete
Returns dict with added/removed counts.
"""
added = 0
removed = 0
# Build set of all relative file paths on disk
disk_files = set()
for dirpath, dirnames, filenames in os.walk(FILE_STORAGE_PATH):
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
for fname in filenames:
if fname in SKIP_FILES or fname.startswith("."):
continue
abs_path = os.path.join(dirpath, fname)
rel_path = os.path.relpath(abs_path, FILE_STORAGE_PATH)
disk_files.add(rel_path)
# Get ALL DB records (including soft-deleted) to avoid re-creating deleted files
result = await db.execute(text(
"SELECT id, storage_path, is_deleted FROM files"
))
db_records = [dict(r._mapping) for r in result]
# Build lookup maps
all_db_paths = {r["storage_path"] for r in db_records}
active_db_paths = {r["storage_path"] for r in db_records if not r["is_deleted"]}
deleted_on_disk = {r["storage_path"]: r for r in db_records
if r["is_deleted"] and r["storage_path"] in disk_files}
# New on disk, not in DB at all → create record
new_files = disk_files - all_db_paths
for rel_path in new_files:
abs_path = os.path.join(FILE_STORAGE_PATH, rel_path)
filename = os.path.basename(rel_path)
mime_type = mimetypes.guess_type(filename)[0]
try:
size_bytes = os.path.getsize(abs_path)
except OSError:
continue
repo = BaseRepository("files", db)
await repo.create({
"filename": filename,
"original_filename": filename,
"storage_path": rel_path,
"mime_type": mime_type,
"size_bytes": size_bytes,
})
added += 1
# Soft-deleted in DB but still on disk → restore
for rel_path, record in deleted_on_disk.items():
repo = BaseRepository("files", db)
await repo.restore(record["id"])
added += 1
# Active in DB but missing from disk → soft-delete
missing_files = active_db_paths - disk_files
for record in db_records:
if record["storage_path"] in missing_files and not record["is_deleted"]:
repo = BaseRepository("files", db)
await repo.soft_delete(record["id"])
removed += 1
return {"added": added, "removed": removed}
SORT_OPTIONS = {
"path": "storage_path ASC",
"path_desc": "storage_path DESC",
"name": "original_filename ASC",
"name_desc": "original_filename DESC",
"date": "created_at DESC",
"date_asc": "created_at ASC",
}
@router.get("/")
async def list_files(
request: Request,
folder: Optional[str] = None,
sort: Optional[str] = None,
context_type: Optional[str] = None,
context_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
# Auto-sync on page load
sync_result = await sync_files(db)
folders = get_folders()
order_by = SORT_OPTIONS.get(sort, "storage_path ASC")
if context_type and context_id:
# Files attached to a specific entity
result = await db.execute(text(f"""
SELECT f.*, fm.context_type, fm.context_id
FROM files f
JOIN file_mappings fm ON fm.file_id = f.id
WHERE f.is_deleted = false AND fm.context_type = :ct AND fm.context_id = :cid
ORDER BY {order_by}
"""), {"ct": context_type, "cid": context_id})
elif folder is not None:
if folder == "":
# Root folder: files with no directory separator in storage_path
result = await db.execute(text(f"""
SELECT * FROM files
WHERE is_deleted = false AND storage_path NOT LIKE '%/%'
ORDER BY {order_by}
"""))
else:
# Specific folder: storage_path starts with folder/
result = await db.execute(text(f"""
SELECT * FROM files
WHERE is_deleted = false AND storage_path LIKE :prefix
ORDER BY {order_by}
"""), {"prefix": folder + "/%"})
else:
# All files
result = await db.execute(text(f"""
SELECT * FROM files
WHERE is_deleted = false
ORDER BY {order_by}
"""))
items = [dict(r._mapping) for r in result]
# Add derived folder field for display
for item in items:
dirname = os.path.dirname(item["storage_path"])
item["folder"] = dirname if dirname else "/"
return templates.TemplateResponse("files.html", {
"request": request, "sidebar": sidebar, "items": items,
"folders": folders, "current_folder": folder,
"current_sort": sort or "path",
"sync_result": sync_result,
"context_type": context_type or "",
"context_id": context_id or "",
"page_title": "Files", "active_nav": "files",
})
@router.get("/upload")
async def upload_form(
request: Request,
folder: Optional[str] = None,
context_type: Optional[str] = None,
context_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
folders = get_folders()
return templates.TemplateResponse("file_upload.html", {
"request": request, "sidebar": sidebar,
"folders": folders, "prefill_folder": folder or "",
"context_type": context_type or "",
"context_id": context_id or "",
"page_title": "Upload File", "active_nav": "files",
})
@router.post("/upload")
async def upload_file(
request: Request,
file: UploadFile = FastAPIFile(...),
description: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
folder: Optional[str] = Form(None),
new_folder: Optional[str] = Form(None),
context_type: Optional[str] = Form(None),
context_id: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
# Determine target folder
target_folder = ""
if new_folder and new_folder.strip():
target_folder = new_folder.strip().strip("/")
elif folder and folder.strip():
target_folder = folder.strip()
# Build absolute folder path and ensure it exists
if target_folder:
folder_abs = os.path.join(FILE_STORAGE_PATH, target_folder)
else:
folder_abs = FILE_STORAGE_PATH
os.makedirs(folder_abs, exist_ok=True)
# Use original filename, handle collisions
original = file.filename or "unknown"
safe_name = original.replace("/", "_").replace("\\", "_")
final_name = resolve_collision(folder_abs, safe_name)
# Build relative storage path
if target_folder:
storage_path = os.path.join(target_folder, final_name)
else:
storage_path = final_name
abs_path = os.path.join(FILE_STORAGE_PATH, storage_path)
# Save to disk
with open(abs_path, "wb") as f:
content = await file.read()
f.write(content)
size_bytes = len(content)
# Insert file record
repo = BaseRepository("files", db)
data = {
"filename": final_name,
"original_filename": original,
"storage_path": storage_path,
"mime_type": file.content_type,
"size_bytes": size_bytes,
"description": description,
}
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
new_file = await repo.create(data)
# Create file mapping if context provided
if context_type and context_type.strip() and context_id and context_id.strip():
await db.execute(text("""
INSERT INTO file_mappings (file_id, context_type, context_id)
VALUES (:fid, :ct, :cid)
ON CONFLICT DO NOTHING
"""), {"fid": new_file["id"], "ct": context_type, "cid": context_id})
# Redirect back to context or file list
if context_type and context_id:
return RedirectResponse(
url=f"/files?context_type={context_type}&context_id={context_id}",
status_code=303,
)
return RedirectResponse(url="/files", status_code=303)
@router.post("/sync")
async def manual_sync(request: Request, db: AsyncSession = Depends(get_db)):
"""Manual sync trigger."""
await sync_files(db)
return RedirectResponse(url="/files", status_code=303)
@router.get("/{file_id}/download")
async def download_file(file_id: str, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("files", db)
item = await repo.get(file_id)
if not item:
return RedirectResponse(url="/files", status_code=303)
abs_path = _resolve_path(item)
if not os.path.exists(abs_path):
return RedirectResponse(url="/files", status_code=303)
return FileResponse(
path=abs_path,
filename=item["original_filename"],
media_type=item.get("mime_type") or "application/octet-stream",
)
@router.get("/{file_id}/preview")
async def preview_file(file_id: str, request: Request, db: AsyncSession = Depends(get_db)):
"""Inline preview for images and PDFs."""
repo = BaseRepository("files", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(file_id)
if not item:
return RedirectResponse(url="/files", status_code=303)
can_preview = item.get("mime_type", "") in PREVIEWABLE
folder = os.path.dirname(item["storage_path"])
return templates.TemplateResponse("file_preview.html", {
"request": request, "sidebar": sidebar, "item": item,
"can_preview": can_preview,
"folder": folder if folder else "/",
"page_title": item["original_filename"], "active_nav": "files",
})
@router.get("/{file_id}/serve")
async def serve_file(file_id: str, db: AsyncSession = Depends(get_db)):
"""Serve file inline (for img src, iframe, etc)."""
repo = BaseRepository("files", db)
item = await repo.get(file_id)
if not item:
return RedirectResponse(url="/files", status_code=303)
abs_path = _resolve_path(item)
if not os.path.exists(abs_path):
return RedirectResponse(url="/files", status_code=303)
mime = item.get("mime_type") or "application/octet-stream"
# Wrap text files in HTML with forced white background / dark text
if mime.startswith("text/"):
try:
with open(abs_path, "r", errors="replace") as f:
text_content = f.read()
except Exception:
return FileResponse(path=abs_path, media_type=mime)
from html import escape
html = (
'<!DOCTYPE html><html><head><meta charset="utf-8">'
'<style>body{background:#fff;color:#1a1a1a;font-family:monospace;'
'font-size:14px;padding:16px;margin:0;white-space:pre-wrap;'
'word-wrap:break-word;}</style></head><body>'
f'{escape(text_content)}</body></html>'
)
return HTMLResponse(content=html)
return FileResponse(path=abs_path, media_type=mime)
@router.post("/{file_id}/delete")
async def delete_file(file_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("files", db)
await repo.soft_delete(file_id)
referer = request.headers.get("referer", "/files")
return RedirectResponse(url=referer, status_code=303)

146
routers/focus.py Normal file
View File

@@ -0,0 +1,146 @@
"""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,
domain_id: Optional[str] = None,
area_id: Optional[str] = None,
project_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
target_date = date.fromisoformat(focus_date) if focus_date else 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)
avail_where = [
"t.is_deleted = false",
"t.status NOT IN ('done', 'cancelled')",
"t.id NOT IN (SELECT task_id FROM daily_focus WHERE focus_date = :target_date AND is_deleted = false)",
]
avail_params = {"target_date": target_date}
if domain_id:
avail_where.append("t.domain_id = :domain_id")
avail_params["domain_id"] = domain_id
if area_id:
avail_where.append("t.area_id = :area_id")
avail_params["area_id"] = area_id
if project_id:
avail_where.append("t.project_id = :project_id")
avail_params["project_id"] = project_id
avail_sql = " AND ".join(avail_where)
result = await db.execute(text(f"""
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 {avail_sql}
ORDER BY t.priority ASC, t.due_date ASC NULLS LAST
LIMIT 50
"""), avail_params)
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)
# Filter options
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
areas_repo = BaseRepository("areas", db)
areas = await areas_repo.list()
projects_repo = BaseRepository("projects", db)
projects = await projects_repo.list()
return templates.TemplateResponse("focus.html", {
"request": request, "sidebar": sidebar,
"items": items, "available_tasks": available_tasks,
"focus_date": target_date,
"total_estimated": total_est,
"domains": domains, "areas": areas, "projects": projects,
"current_domain_id": domain_id or "",
"current_area_id": area_id or "",
"current_project_id": project_id or "",
"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)
parsed_date = date.fromisoformat(focus_date)
# 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": parsed_date})
next_order = result.scalar()
await repo.create({
"task_id": task_id, "focus_date": parsed_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)

90
routers/history.py Normal file
View File

@@ -0,0 +1,90 @@
"""Change History: reverse-chronological feed of recently modified items."""
from fastapi import APIRouter, Request, Depends
from fastapi.templating import Jinja2Templates
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from typing import Optional
from datetime import datetime, timezone
from core.database import get_db
from core.sidebar import get_sidebar_data
router = APIRouter(prefix="/history", tags=["history"])
templates = Jinja2Templates(directory="templates")
# Entity configs: (table, label_column, type_label, url_prefix)
HISTORY_ENTITIES = [
("domains", "name", "Domain", "/domains"),
("areas", "name", "Area", "/areas"),
("projects", "name", "Project", "/projects"),
("tasks", "title", "Task", "/tasks"),
("notes", "title", "Note", "/notes"),
("contacts", "first_name", "Contact", "/contacts"),
("meetings", "title", "Meeting", "/meetings"),
("decisions", "title", "Decision", "/decisions"),
("lists", "name", "List", "/lists"),
("appointments", "title", "Appointment", "/appointments"),
("links", "label", "Link", "/links"),
("files", "original_filename", "File", "/files"),
("capture", "content", "Capture", "/capture"),
]
@router.get("/")
async def history_view(
request: Request,
entity_type: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
all_items = []
for table, label_col, type_label, url_prefix in HISTORY_ENTITIES:
if entity_type and entity_type != table:
continue
try:
result = await db.execute(text(f"""
SELECT id, {label_col} as label, updated_at, created_at
FROM {table}
WHERE is_deleted = false
ORDER BY updated_at DESC
LIMIT 20
"""))
for r in result:
row = dict(r._mapping)
# Determine action
action = "created"
if row["updated_at"] and row["created_at"]:
diff = abs((row["updated_at"] - row["created_at"]).total_seconds())
if diff > 1:
action = "modified"
all_items.append({
"type": table,
"type_label": type_label,
"id": str(row["id"]),
"label": str(row["label"] or "Untitled")[:80],
"url": f"{url_prefix}/{row['id']}",
"updated_at": row["updated_at"],
"action": action,
})
except Exception:
continue
# Sort by updated_at descending, take top 50
all_items.sort(key=lambda x: x["updated_at"] or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
all_items = all_items[:50]
# Build entity type options for filter
type_options = [{"value": t[0], "label": t[2]} for t in HISTORY_ENTITIES]
return templates.TemplateResponse("history.html", {
"request": request, "sidebar": sidebar,
"items": all_items,
"type_options": type_options,
"current_type": entity_type or "",
"page_title": "Change History", "active_nav": "history",
})

156
routers/links.py Normal file
View File

@@ -0,0 +1,156 @@
"""Links: URL references attached to domains/projects/tasks/meetings."""
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
from routers.weblinks import get_default_folder_id
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,
task_id: Optional[str] = None,
meeting_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 "",
"prefill_task_id": task_id or "",
"prefill_meeting_id": meeting_id or "",
})
@router.post("/create")
async def create_link(
request: Request, label: str = Form(...), url: str = Form(...),
domain_id: Optional[str] = Form(None), project_id: Optional[str] = Form(None),
task_id: Optional[str] = Form(None), meeting_id: Optional[str] = Form(None),
description: Optional[str] = Form(None), tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("links", db)
data = {"label": label, "url": url, "description": description}
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 task_id and task_id.strip():
data["task_id"] = task_id
if meeting_id and meeting_id.strip():
data["meeting_id"] = meeting_id
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
link = await repo.create(data)
# Assign to Default folder
default_fid = await get_default_folder_id(db)
await db.execute(text("""
INSERT INTO folder_links (folder_id, link_id)
VALUES (:fid, :lid) ON CONFLICT DO NOTHING
"""), {"fid": default_fid, "lid": link["id"]})
# Redirect back to context if created from task/meeting
if task_id and task_id.strip():
return RedirectResponse(url=f"/tasks/{task_id}?tab=links", status_code=303)
if meeting_id and meeting_id.strip():
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=links", status_code=303)
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": "",
"prefill_task_id": "", "prefill_meeting_id": "",
})
@router.post("/{link_id}/edit")
async def update_link(
link_id: str, label: str = Form(...), url: str = Form(...),
domain_id: Optional[str] = Form(None), project_id: Optional[str] = Form(None),
description: Optional[str] = Form(None), tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("links", db)
data = {
"label": label, "url": url,
"domain_id": domain_id if domain_id and domain_id.strip() else None,
"project_id": project_id if project_id and project_id.strip() else None,
"description": description,
}
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
else:
data["tags"] = None
await repo.update(link_id, data)
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)

334
routers/lists.py Normal file
View File

@@ -0,0 +1,334 @@
"""Lists: checklist/ordered list management with inline items."""
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="/lists", tags=["lists"])
templates = Jinja2Templates(directory="templates")
@router.get("/")
async def list_lists(
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 = ["l.is_deleted = false"]
params = {}
if domain_id:
where_clauses.append("l.domain_id = :domain_id")
params["domain_id"] = domain_id
if project_id:
where_clauses.append("l.project_id = :project_id")
params["project_id"] = project_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,
(SELECT count(*) FROM list_items li WHERE li.list_id = l.id AND li.is_deleted = false) as item_count,
(SELECT count(*) FROM list_items li WHERE li.list_id = l.id AND li.is_deleted = false AND li.completed = true) as completed_count
FROM lists 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 DESC
"""), params)
items = [dict(r._mapping) for r in result]
# Filter options
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
projects_repo = BaseRepository("projects", db)
projects = await projects_repo.list()
return templates.TemplateResponse("lists.html", {
"request": request, "sidebar": sidebar, "items": items,
"domains": domains, "projects": projects,
"current_domain_id": domain_id or "",
"current_project_id": project_id or "",
"page_title": "Lists", "active_nav": "lists",
})
@router.get("/create")
async def create_form(
request: Request,
domain_id: Optional[str] = None,
project_id: Optional[str] = None,
task_id: Optional[str] = None,
meeting_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()
areas_repo = BaseRepository("areas", db)
areas = await areas_repo.list()
return templates.TemplateResponse("list_form.html", {
"request": request, "sidebar": sidebar,
"domains": domains, "projects": projects, "areas": areas,
"page_title": "New List", "active_nav": "lists",
"item": None,
"prefill_domain_id": domain_id or "",
"prefill_project_id": project_id or "",
"prefill_task_id": task_id or "",
"prefill_meeting_id": meeting_id or "",
})
@router.post("/create")
async def create_list(
request: Request,
name: str = Form(...),
domain_id: str = Form(...),
area_id: Optional[str] = Form(None),
project_id: Optional[str] = Form(None),
task_id: Optional[str] = Form(None),
meeting_id: Optional[str] = Form(None),
list_type: str = Form("checklist"),
description: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("lists", db)
data = {
"name": name, "domain_id": domain_id,
"list_type": list_type,
"description": description,
}
if area_id and area_id.strip():
data["area_id"] = area_id
if project_id and project_id.strip():
data["project_id"] = project_id
if task_id and task_id.strip():
data["task_id"] = task_id
if meeting_id and meeting_id.strip():
data["meeting_id"] = meeting_id
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
new_list = await repo.create(data)
if task_id and task_id.strip():
return RedirectResponse(url=f"/tasks/{task_id}?tab=lists", status_code=303)
if meeting_id and meeting_id.strip():
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=lists", status_code=303)
return RedirectResponse(url=f"/lists/{new_list['id']}", status_code=303)
@router.get("/{list_id}")
async def list_detail(list_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("lists", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(list_id)
if not item:
return RedirectResponse(url="/lists", status_code=303)
# Domain/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
# List items (ordered, with hierarchy)
result = await db.execute(text("""
SELECT * FROM list_items
WHERE list_id = :list_id AND is_deleted = false
ORDER BY sort_order, created_at
"""), {"list_id": list_id})
list_items = [dict(r._mapping) for r in result]
# Separate top-level and child items
top_items = [i for i in list_items if i.get("parent_item_id") is None]
child_map = {}
for i in list_items:
pid = i.get("parent_item_id")
if pid:
child_map.setdefault(str(pid), []).append(i)
# Contacts linked to this list
result = await db.execute(text("""
SELECT c.*, cl.role, cl.created_at as linked_at
FROM contacts c
JOIN contact_lists cl ON cl.contact_id = c.id
WHERE cl.list_id = :lid AND c.is_deleted = false
ORDER BY c.first_name
"""), {"lid": list_id})
contacts = [dict(r._mapping) for r in result]
# All contacts for add dropdown
result = await db.execute(text("""
SELECT id, first_name, last_name FROM contacts
WHERE is_deleted = false ORDER BY first_name
"""))
all_contacts = [dict(r._mapping) for r in result]
return templates.TemplateResponse("list_detail.html", {
"request": request, "sidebar": sidebar, "item": item,
"domain": domain, "project": project,
"list_items": top_items, "child_map": child_map,
"contacts": contacts, "all_contacts": all_contacts,
"page_title": item["name"], "active_nav": "lists",
})
@router.get("/{list_id}/edit")
async def edit_form(list_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("lists", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(list_id)
if not item:
return RedirectResponse(url="/lists", status_code=303)
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
projects_repo = BaseRepository("projects", db)
projects = await projects_repo.list()
areas_repo = BaseRepository("areas", db)
areas = await areas_repo.list()
return templates.TemplateResponse("list_form.html", {
"request": request, "sidebar": sidebar,
"domains": domains, "projects": projects, "areas": areas,
"page_title": "Edit List", "active_nav": "lists",
"item": item,
"prefill_domain_id": "", "prefill_project_id": "",
})
@router.post("/{list_id}/edit")
async def update_list(
list_id: str,
name: str = Form(...),
domain_id: str = Form(...),
area_id: Optional[str] = Form(None),
project_id: Optional[str] = Form(None),
list_type: str = Form("checklist"),
description: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("lists", db)
data = {
"name": name, "domain_id": domain_id,
"list_type": list_type, "description": description,
"area_id": area_id if area_id and area_id.strip() else None,
"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(list_id, data)
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)
@router.post("/{list_id}/delete")
async def delete_list(list_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("lists", db)
await repo.soft_delete(list_id)
return RedirectResponse(url="/lists", status_code=303)
# ---- List Items ----
@router.post("/{list_id}/items/add")
async def add_item(
list_id: str,
request: Request,
content: str = Form(...),
parent_item_id: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("list_items", db)
data = {"list_id": list_id, "content": content, "completed": False}
if parent_item_id and parent_item_id.strip():
data["parent_item_id"] = parent_item_id
await repo.create(data)
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)
@router.post("/{list_id}/items/{item_id}/toggle")
async def toggle_item(list_id: str, item_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("list_items", db)
item = await repo.get(item_id)
if not item:
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)
if item["completed"]:
await repo.update(item_id, {"completed": False, "completed_at": None})
else:
await repo.update(item_id, {"completed": True, "completed_at": datetime.now(timezone.utc)})
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)
@router.post("/{list_id}/items/{item_id}/delete")
async def delete_item(list_id: str, item_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("list_items", db)
await repo.soft_delete(item_id)
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)
@router.post("/{list_id}/items/{item_id}/edit")
async def edit_item(
list_id: str,
item_id: str,
content: str = Form(...),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("list_items", db)
await repo.update(item_id, {"content": content})
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)
# ---- Contact linking ----
@router.post("/{list_id}/contacts/add")
async def add_contact(
list_id: str,
contact_id: str = Form(...),
role: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
await db.execute(text("""
INSERT INTO contact_lists (contact_id, list_id, role)
VALUES (:cid, :lid, :role) ON CONFLICT DO NOTHING
"""), {"cid": contact_id, "lid": list_id, "role": role if role and role.strip() else None})
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)
@router.post("/{list_id}/contacts/{contact_id}/remove")
async def remove_contact(
list_id: str, contact_id: str,
db: AsyncSession = Depends(get_db),
):
await db.execute(text(
"DELETE FROM contact_lists WHERE contact_id = :cid AND list_id = :lid"
), {"cid": contact_id, "lid": list_id})
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)

406
routers/meetings.py Normal file
View File

@@ -0,0 +1,406 @@
"""Meetings: CRUD with agenda, transcript, notes, and action item -> task 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 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="/meetings", tags=["meetings"])
templates = Jinja2Templates(directory="templates")
@router.get("/")
async def list_meetings(
request: Request,
status: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
where_clauses = ["m.is_deleted = false"]
params = {}
if status:
where_clauses.append("m.status = :status")
params["status"] = status
where_sql = " AND ".join(where_clauses)
result = await db.execute(text(f"""
SELECT m.*,
(SELECT count(*) FROM meeting_tasks mt
JOIN tasks t ON mt.task_id = t.id
WHERE mt.meeting_id = m.id AND t.is_deleted = false) as action_count
FROM meetings m
WHERE {where_sql}
ORDER BY m.meeting_date DESC, m.start_at DESC NULLS LAST
"""), params)
items = [dict(r._mapping) for r in result]
return templates.TemplateResponse("meetings.html", {
"request": request, "sidebar": sidebar, "items": items,
"current_status": status or "",
"page_title": "Meetings", "active_nav": "meetings",
})
@router.get("/create")
async def create_form(request: Request, db: AsyncSession = Depends(get_db)):
sidebar = await get_sidebar_data(db)
# Get contacts for attendee selection
contacts_repo = BaseRepository("contacts", db)
contacts = await contacts_repo.list()
# Parent meetings for series
result = await db.execute(text("""
SELECT id, title, meeting_date FROM meetings
WHERE is_deleted = false ORDER BY meeting_date DESC LIMIT 50
"""))
parent_meetings = [dict(r._mapping) for r in result]
return templates.TemplateResponse("meeting_form.html", {
"request": request, "sidebar": sidebar,
"contacts": contacts, "parent_meetings": parent_meetings,
"page_title": "New Meeting", "active_nav": "meetings",
"item": None,
})
@router.post("/create")
async def create_meeting(
request: Request,
title: str = Form(...),
meeting_date: str = Form(...),
start_at: Optional[str] = Form(None),
end_at: Optional[str] = Form(None),
location: Optional[str] = Form(None),
status: str = Form("scheduled"),
priority: Optional[str] = Form(None),
parent_id: Optional[str] = Form(None),
agenda: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("meetings", db)
data = {
"title": title, "meeting_date": meeting_date,
"status": status, "location": location, "agenda": agenda,
}
if start_at and start_at.strip():
data["start_at"] = start_at
if end_at and end_at.strip():
data["end_at"] = end_at
if priority and priority.strip():
data["priority"] = int(priority)
if parent_id and parent_id.strip():
data["parent_id"] = parent_id
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
meeting = await repo.create(data)
return RedirectResponse(url=f"/meetings/{meeting['id']}", status_code=303)
@router.get("/{meeting_id}")
async def meeting_detail(
meeting_id: str, request: Request,
tab: str = "overview",
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("meetings", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(meeting_id)
if not item:
return RedirectResponse(url="/meetings", status_code=303)
# Linked projects (always shown in header)
result = await db.execute(text("""
SELECT p.id, p.name, d.color as domain_color
FROM projects p
JOIN project_meetings pm ON pm.project_id = p.id
LEFT JOIN domains d ON p.domain_id = d.id
WHERE pm.meeting_id = :mid AND p.is_deleted = false
ORDER BY p.name
"""), {"mid": meeting_id})
projects = [dict(r._mapping) for r in result]
# Overview data (always needed for overview tab)
action_items = []
decisions = []
domains = []
tab_data = []
all_contacts = []
all_decisions = []
if tab == "overview":
# Action items
result = await db.execute(text("""
SELECT t.*, mt.source,
d.name as domain_name, d.color as domain_color,
p.name as project_name
FROM meeting_tasks mt
JOIN tasks t ON mt.task_id = t.id
LEFT JOIN domains d ON t.domain_id = d.id
LEFT JOIN projects p ON t.project_id = p.id
WHERE mt.meeting_id = :mid AND t.is_deleted = false
ORDER BY t.sort_order, t.created_at
"""), {"mid": meeting_id})
action_items = [dict(r._mapping) for r in result]
# Decisions from this meeting
result = await db.execute(text("""
SELECT * FROM decisions
WHERE meeting_id = :mid AND is_deleted = false
ORDER BY created_at
"""), {"mid": meeting_id})
decisions = [dict(r._mapping) for r in result]
# Domains for action item creation
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
elif tab == "notes":
result = await db.execute(text("""
SELECT * FROM notes
WHERE meeting_id = :mid AND is_deleted = false
ORDER BY updated_at DESC
"""), {"mid": meeting_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "links":
result = await db.execute(text("""
SELECT * FROM links
WHERE meeting_id = :mid AND is_deleted = false
ORDER BY sort_order, label
"""), {"mid": meeting_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "files":
result = await db.execute(text("""
SELECT f.* FROM files f
JOIN file_mappings fm ON fm.file_id = f.id
WHERE fm.context_type = 'meeting' AND fm.context_id = :mid AND f.is_deleted = false
ORDER BY f.created_at DESC
"""), {"mid": meeting_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "lists":
result = await db.execute(text("""
SELECT l.*,
(SELECT count(*) FROM list_items li WHERE li.list_id = l.id AND li.is_deleted = false) as item_count
FROM lists l
WHERE l.meeting_id = :mid AND l.is_deleted = false
ORDER BY l.sort_order, l.created_at DESC
"""), {"mid": meeting_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "decisions":
result = await db.execute(text("""
SELECT * FROM decisions
WHERE meeting_id = :mid AND is_deleted = false
ORDER BY created_at DESC
"""), {"mid": meeting_id})
tab_data = [dict(r._mapping) for r in result]
result = await db.execute(text("""
SELECT id, title FROM decisions
WHERE (meeting_id IS NULL) AND is_deleted = false
ORDER BY created_at DESC LIMIT 50
"""))
all_decisions = [dict(r._mapping) for r in result]
elif tab == "contacts":
result = await db.execute(text("""
SELECT c.*, cm.role, cm.created_at as linked_at
FROM contacts c
JOIN contact_meetings cm ON cm.contact_id = c.id
WHERE cm.meeting_id = :mid AND c.is_deleted = false
ORDER BY c.first_name
"""), {"mid": meeting_id})
tab_data = [dict(r._mapping) for r in result]
result = await db.execute(text("""
SELECT id, first_name, last_name FROM contacts
WHERE is_deleted = false ORDER BY first_name
"""))
all_contacts = [dict(r._mapping) for r in result]
# Tab counts
counts = {}
for count_tab, count_sql in [
("notes", "SELECT count(*) FROM notes WHERE meeting_id = :mid AND is_deleted = false"),
("links", "SELECT count(*) FROM links WHERE meeting_id = :mid AND is_deleted = false"),
("files", "SELECT count(*) FROM files f JOIN file_mappings fm ON fm.file_id = f.id WHERE fm.context_type = 'meeting' AND fm.context_id = :mid AND f.is_deleted = false"),
("lists", "SELECT count(*) FROM lists WHERE meeting_id = :mid AND is_deleted = false"),
("decisions", "SELECT count(*) FROM decisions WHERE meeting_id = :mid AND is_deleted = false"),
("contacts", "SELECT count(*) FROM contacts c JOIN contact_meetings cm ON cm.contact_id = c.id WHERE cm.meeting_id = :mid AND c.is_deleted = false"),
]:
result = await db.execute(text(count_sql), {"mid": meeting_id})
counts[count_tab] = result.scalar() or 0
return templates.TemplateResponse("meeting_detail.html", {
"request": request, "sidebar": sidebar, "item": item,
"action_items": action_items, "decisions": decisions,
"domains": domains, "projects": projects,
"tab": tab, "tab_data": tab_data,
"all_contacts": all_contacts, "all_decisions": all_decisions,
"counts": counts,
"page_title": item["title"], "active_nav": "meetings",
})
@router.get("/{meeting_id}/edit")
async def edit_form(meeting_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("meetings", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(meeting_id)
if not item:
return RedirectResponse(url="/meetings", status_code=303)
contacts_repo = BaseRepository("contacts", db)
contacts = await contacts_repo.list()
result = await db.execute(text("""
SELECT id, title, meeting_date FROM meetings
WHERE is_deleted = false AND id != :mid ORDER BY meeting_date DESC LIMIT 50
"""), {"mid": meeting_id})
parent_meetings = [dict(r._mapping) for r in result]
return templates.TemplateResponse("meeting_form.html", {
"request": request, "sidebar": sidebar,
"contacts": contacts, "parent_meetings": parent_meetings,
"page_title": "Edit Meeting", "active_nav": "meetings",
"item": item,
})
@router.post("/{meeting_id}/edit")
async def update_meeting(
meeting_id: str,
title: str = Form(...),
meeting_date: str = Form(...),
start_at: Optional[str] = Form(None),
end_at: Optional[str] = Form(None),
location: Optional[str] = Form(None),
status: str = Form("scheduled"),
priority: Optional[str] = Form(None),
parent_id: Optional[str] = Form(None),
agenda: Optional[str] = Form(None),
transcript: Optional[str] = Form(None),
notes_body: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("meetings", db)
data = {
"title": title, "meeting_date": meeting_date,
"status": status,
"location": location if location and location.strip() else None,
"agenda": agenda if agenda and agenda.strip() else None,
"transcript": transcript if transcript and transcript.strip() else None,
"notes_body": notes_body if notes_body and notes_body.strip() else None,
"start_at": start_at if start_at and start_at.strip() else None,
"end_at": end_at if end_at and end_at.strip() else None,
"parent_id": parent_id if parent_id and parent_id.strip() else None,
}
if priority and priority.strip():
data["priority"] = int(priority)
else:
data["priority"] = 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(meeting_id, data)
return RedirectResponse(url=f"/meetings/{meeting_id}", status_code=303)
@router.post("/{meeting_id}/delete")
async def delete_meeting(meeting_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("meetings", db)
await repo.soft_delete(meeting_id)
return RedirectResponse(url="/meetings", status_code=303)
# ---- Action Items ----
@router.post("/{meeting_id}/action-item")
async def create_action_item(
meeting_id: str,
request: Request,
title: str = Form(...),
domain_id: str = Form(...),
db: AsyncSession = Depends(get_db),
):
"""Create a task and link it to this meeting as an action item."""
task_repo = BaseRepository("tasks", db)
task = await task_repo.create({
"title": title,
"domain_id": domain_id,
"status": "open",
"priority": 2,
})
# Link via meeting_tasks junction
await db.execute(text("""
INSERT INTO meeting_tasks (meeting_id, task_id, source)
VALUES (:mid, :tid, 'action_item')
ON CONFLICT DO NOTHING
"""), {"mid": meeting_id, "tid": task["id"]})
return RedirectResponse(url=f"/meetings/{meeting_id}", status_code=303)
# ---- Decision linking ----
@router.post("/{meeting_id}/decisions/add")
async def add_decision(
meeting_id: str,
decision_id: str = Form(...),
db: AsyncSession = Depends(get_db),
):
await db.execute(text("""
UPDATE decisions SET meeting_id = :mid WHERE id = :did
"""), {"mid": meeting_id, "did": decision_id})
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=decisions", status_code=303)
@router.post("/{meeting_id}/decisions/{decision_id}/remove")
async def remove_decision(
meeting_id: str, decision_id: str,
db: AsyncSession = Depends(get_db),
):
await db.execute(text("""
UPDATE decisions SET meeting_id = NULL WHERE id = :did AND meeting_id = :mid
"""), {"did": decision_id, "mid": meeting_id})
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=decisions", status_code=303)
# ---- Contact linking ----
@router.post("/{meeting_id}/contacts/add")
async def add_contact(
meeting_id: str,
contact_id: str = Form(...),
role: str = Form("attendee"),
db: AsyncSession = Depends(get_db),
):
await db.execute(text("""
INSERT INTO contact_meetings (contact_id, meeting_id, role)
VALUES (:cid, :mid, :role) ON CONFLICT DO NOTHING
"""), {"cid": contact_id, "mid": meeting_id, "role": role if role and role.strip() else "attendee"})
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=contacts", status_code=303)
@router.post("/{meeting_id}/contacts/{contact_id}/remove")
async def remove_contact(
meeting_id: str, contact_id: str,
db: AsyncSession = Depends(get_db),
):
await db.execute(text(
"DELETE FROM contact_meetings WHERE contact_id = :cid AND meeting_id = :mid"
), {"cid": contact_id, "mid": meeting_id})
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=contacts", status_code=303)

195
routers/notes.py Normal file
View File

@@ -0,0 +1,195 @@
"""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,
task_id: Optional[str] = None,
meeting_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 "",
"prefill_task_id": task_id or "",
"prefill_meeting_id": meeting_id or "",
})
@router.post("/create")
async def create_note(
request: Request,
title: str = Form(...),
domain_id: str = Form(...),
project_id: Optional[str] = Form(None),
task_id: Optional[str] = Form(None),
meeting_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 task_id and task_id.strip():
data["task_id"] = task_id
if meeting_id and meeting_id.strip():
data["meeting_id"] = meeting_id
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
note = await repo.create(data)
if task_id and task_id.strip():
return RedirectResponse(url=f"/tasks/{task_id}?tab=notes", status_code=303)
if meeting_id and meeting_id.strip():
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=notes", status_code=303)
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)

569
routers/processes.py Normal file
View File

@@ -0,0 +1,569 @@
"""Processes: reusable workflows/checklists with runs and step tracking."""
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="/processes", tags=["processes"])
templates = Jinja2Templates(directory="templates")
# ── Process Template CRUD ─────────────────────────────────────
@router.get("/")
async def list_processes(
request: Request,
status: Optional[str] = None,
process_type: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
filters = {}
if status:
filters["status"] = status
if process_type:
filters["process_type"] = process_type
repo = BaseRepository("processes", db)
items = await repo.list(filters=filters, sort="sort_order")
# Get step counts per process
result = await db.execute(text("""
SELECT process_id, count(*) as step_count
FROM process_steps WHERE is_deleted = false
GROUP BY process_id
"""))
step_counts = {str(r.process_id): r.step_count for r in result}
for item in items:
item["step_count"] = step_counts.get(str(item["id"]), 0)
return templates.TemplateResponse("processes.html", {
"request": request, "sidebar": sidebar, "items": items,
"current_status": status or "",
"current_type": process_type or "",
"page_title": "Processes", "active_nav": "processes",
})
@router.get("/create")
async def create_form(request: Request, db: AsyncSession = Depends(get_db)):
sidebar = await get_sidebar_data(db)
return templates.TemplateResponse("processes_form.html", {
"request": request, "sidebar": sidebar, "item": None,
"page_title": "New Process", "active_nav": "processes",
})
@router.post("/create")
async def create_process(
request: Request,
name: str = Form(...),
description: Optional[str] = Form(None),
process_type: str = Form("checklist"),
status: str = Form("draft"),
category: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("processes", db)
data = {
"name": name,
"description": description,
"process_type": process_type,
"status": status,
}
if category and category.strip():
data["category"] = category
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
item = await repo.create(data)
return RedirectResponse(url=f"/processes/{item['id']}", status_code=303)
@router.get("/runs")
async def list_all_runs(
request: Request,
status: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
"""List all runs across all processes."""
sidebar = await get_sidebar_data(db)
where = "pr.is_deleted = false"
params = {}
if status:
where += " AND pr.status = :status"
params["status"] = status
result = await db.execute(text(f"""
SELECT pr.*, p.name as process_name,
proj.name as project_name,
(SELECT count(*) FROM process_run_steps WHERE run_id = pr.id AND is_deleted = false) as total_steps,
(SELECT count(*) FROM process_run_steps WHERE run_id = pr.id AND is_deleted = false AND status = 'completed') as completed_steps
FROM process_runs pr
JOIN processes p ON pr.process_id = p.id
LEFT JOIN projects proj ON pr.project_id = proj.id
WHERE {where}
ORDER BY pr.created_at DESC
"""), params)
items = [dict(r._mapping) for r in result]
return templates.TemplateResponse("process_runs.html", {
"request": request, "sidebar": sidebar, "items": items,
"current_status": status or "",
"page_title": "All Process Runs", "active_nav": "processes",
})
@router.get("/runs/{run_id}")
async def run_detail(run_id: str, request: Request, db: AsyncSession = Depends(get_db)):
"""View a specific process run with step checklist."""
sidebar = await get_sidebar_data(db)
# Get the run with process info
result = await db.execute(text("""
SELECT pr.*, p.name as process_name, p.id as process_id_ref,
proj.name as project_name,
c.first_name as contact_first, c.last_name as contact_last
FROM process_runs pr
JOIN processes p ON pr.process_id = p.id
LEFT JOIN projects proj ON pr.project_id = proj.id
LEFT JOIN contacts c ON pr.contact_id = c.id
WHERE pr.id = :id
"""), {"id": run_id})
run = result.first()
if not run:
return RedirectResponse(url="/processes/runs", status_code=303)
run = dict(run._mapping)
# Get run steps
result = await db.execute(text("""
SELECT * FROM process_run_steps
WHERE run_id = :run_id AND is_deleted = false
ORDER BY sort_order, created_at
"""), {"run_id": run_id})
steps = [dict(r._mapping) for r in result]
total = len(steps)
completed = sum(1 for s in steps if s["status"] == "completed")
# Get linked tasks via junction table
result = await db.execute(text("""
SELECT t.id, t.title, t.status, t.priority,
prt.run_step_id,
p.name as project_name
FROM process_run_tasks prt
JOIN tasks t ON prt.task_id = t.id
LEFT JOIN projects p ON t.project_id = p.id
WHERE prt.run_step_id IN (
SELECT id FROM process_run_steps WHERE run_id = :run_id
)
ORDER BY t.created_at
"""), {"run_id": run_id})
tasks = [dict(r._mapping) for r in result]
# Map tasks to their steps
step_tasks = {}
for task in tasks:
sid = str(task["run_step_id"])
step_tasks.setdefault(sid, []).append(task)
return templates.TemplateResponse("process_run_detail.html", {
"request": request, "sidebar": sidebar,
"run": run, "steps": steps, "tasks": tasks,
"step_tasks": step_tasks,
"total_steps": total, "completed_steps": completed,
"page_title": run["title"], "active_nav": "processes",
})
@router.get("/{process_id}")
async def process_detail(process_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("processes", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(process_id)
if not item:
return RedirectResponse(url="/processes", status_code=303)
# Get steps
result = await db.execute(text("""
SELECT * FROM process_steps
WHERE process_id = :pid AND is_deleted = false
ORDER BY sort_order, created_at
"""), {"pid": process_id})
steps = [dict(r._mapping) for r in result]
# Get runs
result = await db.execute(text("""
SELECT pr.*,
proj.name as project_name,
(SELECT count(*) FROM process_run_steps WHERE run_id = pr.id AND is_deleted = false) as total_steps,
(SELECT count(*) FROM process_run_steps WHERE run_id = pr.id AND is_deleted = false AND status = 'completed') as completed_steps
FROM process_runs pr
LEFT JOIN projects proj ON pr.project_id = proj.id
WHERE pr.process_id = :pid AND pr.is_deleted = false
ORDER BY pr.created_at DESC
"""), {"pid": process_id})
runs = [dict(r._mapping) for r in result]
# Load projects and contacts for "Start Run" form
projects_repo = BaseRepository("projects", db)
projects = await projects_repo.list()
contacts_repo = BaseRepository("contacts", db)
contacts = await contacts_repo.list()
return templates.TemplateResponse("processes_detail.html", {
"request": request, "sidebar": sidebar,
"item": item, "steps": steps, "runs": runs,
"projects": projects, "contacts": contacts,
"page_title": item["name"], "active_nav": "processes",
})
@router.get("/{process_id}/edit")
async def edit_form(process_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("processes", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(process_id)
if not item:
return RedirectResponse(url="/processes", status_code=303)
return templates.TemplateResponse("processes_form.html", {
"request": request, "sidebar": sidebar, "item": item,
"page_title": "Edit Process", "active_nav": "processes",
})
@router.post("/{process_id}/edit")
async def update_process(
process_id: str,
name: str = Form(...),
description: Optional[str] = Form(None),
process_type: str = Form("checklist"),
status: str = Form("draft"),
category: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("processes", db)
data = {
"name": name,
"description": description,
"process_type": process_type,
"status": status,
"category": category if category and category.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(process_id, data)
return RedirectResponse(url=f"/processes/{process_id}", status_code=303)
@router.post("/{process_id}/delete")
async def delete_process(process_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("processes", db)
await repo.soft_delete(process_id)
return RedirectResponse(url="/processes", status_code=303)
# ── Process Steps ─────────────────────────────────────────────
@router.post("/{process_id}/steps/add")
async def add_step(
process_id: str,
title: str = Form(...),
instructions: Optional[str] = Form(None),
expected_output: Optional[str] = Form(None),
estimated_days: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
# Get current max sort_order
result = await db.execute(text("""
SELECT coalesce(max(sort_order), -1) + 1 as next_order
FROM process_steps WHERE process_id = :pid AND is_deleted = false
"""), {"pid": process_id})
next_order = result.scalar()
repo = BaseRepository("process_steps", db)
data = {
"process_id": process_id,
"title": title,
"sort_order": next_order,
}
if instructions and instructions.strip():
data["instructions"] = instructions
if expected_output and expected_output.strip():
data["expected_output"] = expected_output
if estimated_days and estimated_days.strip():
data["estimated_days"] = int(estimated_days)
await repo.create(data)
return RedirectResponse(url=f"/processes/{process_id}", status_code=303)
@router.post("/{process_id}/steps/{step_id}/edit")
async def edit_step(
process_id: str,
step_id: str,
title: str = Form(...),
instructions: Optional[str] = Form(None),
expected_output: Optional[str] = Form(None),
estimated_days: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("process_steps", db)
data = {
"title": title,
"instructions": instructions if instructions and instructions.strip() else None,
"expected_output": expected_output if expected_output and expected_output.strip() else None,
"estimated_days": int(estimated_days) if estimated_days and estimated_days.strip() else None,
}
await repo.update(step_id, data)
return RedirectResponse(url=f"/processes/{process_id}", status_code=303)
@router.post("/{process_id}/steps/{step_id}/delete")
async def delete_step(process_id: str, step_id: str, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("process_steps", db)
await repo.soft_delete(step_id)
return RedirectResponse(url=f"/processes/{process_id}", status_code=303)
@router.post("/{process_id}/steps/reorder")
async def reorder_steps(
process_id: str,
request: Request,
db: AsyncSession = Depends(get_db),
):
form = await request.form()
ids = form.getlist("step_ids")
if ids:
repo = BaseRepository("process_steps", db)
await repo.reorder(ids)
return RedirectResponse(url=f"/processes/{process_id}", status_code=303)
# ── Process Runs ──────────────────────────────────────────────
@router.post("/{process_id}/runs/start")
async def start_run(
process_id: str,
title: str = Form(...),
task_generation: str = Form("all_at_once"),
project_id: Optional[str] = Form(None),
contact_id: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
"""Start a new process run: snapshot steps, optionally generate tasks."""
# Get process
proc_repo = BaseRepository("processes", db)
process = await proc_repo.get(process_id)
if not process:
return RedirectResponse(url="/processes", status_code=303)
# Create the run
run_repo = BaseRepository("process_runs", db)
run_data = {
"process_id": process_id,
"title": title,
"status": "in_progress",
"process_type": process["process_type"],
"task_generation": task_generation,
"started_at": datetime.now(timezone.utc),
}
if project_id and project_id.strip():
run_data["project_id"] = project_id
if contact_id and contact_id.strip():
run_data["contact_id"] = contact_id
run = await run_repo.create(run_data)
# Snapshot steps from the process template
result = await db.execute(text("""
SELECT * FROM process_steps
WHERE process_id = :pid AND is_deleted = false
ORDER BY sort_order, created_at
"""), {"pid": process_id})
template_steps = [dict(r._mapping) for r in result]
step_repo = BaseRepository("process_run_steps", db)
run_steps = []
for step in template_steps:
rs = await step_repo.create({
"run_id": str(run["id"]),
"title": step["title"],
"instructions": step.get("instructions"),
"status": "pending",
"sort_order": step["sort_order"],
})
run_steps.append(rs)
# Task generation
if run_steps:
await _generate_tasks(db, run, run_steps, task_generation)
return RedirectResponse(url=f"/processes/runs/{run['id']}", status_code=303)
async def _generate_tasks(db, run, run_steps, mode):
"""Generate tasks for run steps based on mode."""
task_repo = BaseRepository("tasks", db)
# Get a default domain for tasks
result = await db.execute(text(
"SELECT id FROM domains WHERE is_deleted = false ORDER BY sort_order LIMIT 1"
))
row = result.first()
default_domain_id = str(row[0]) if row else None
if not default_domain_id:
return
if mode == "all_at_once":
steps_to_generate = run_steps
else: # step_by_step
steps_to_generate = [run_steps[0]]
for step in steps_to_generate:
task_data = {
"title": step["title"],
"description": step.get("instructions") or "",
"status": "open",
"priority": 3,
"domain_id": default_domain_id,
}
if run.get("project_id"):
task_data["project_id"] = str(run["project_id"])
task = await task_repo.create(task_data)
# Link via junction table
await db.execute(text("""
INSERT INTO process_run_tasks (run_step_id, task_id)
VALUES (:rsid, :tid)
ON CONFLICT DO NOTHING
"""), {"rsid": str(step["id"]), "tid": str(task["id"])})
@router.post("/runs/{run_id}/steps/{step_id}/complete")
async def complete_step(
run_id: str,
step_id: str,
notes: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
"""Mark a run step as completed."""
now = datetime.now(timezone.utc)
step_repo = BaseRepository("process_run_steps", db)
await step_repo.update(step_id, {
"status": "completed",
"completed_at": now,
"notes": notes if notes and notes.strip() else None,
})
# If step_by_step mode, generate task for next pending step
result = await db.execute(text("""
SELECT pr.task_generation FROM process_runs pr WHERE pr.id = :rid
"""), {"rid": run_id})
run_row = result.first()
if run_row and run_row.task_generation == "step_by_step":
# Find next pending step
result = await db.execute(text("""
SELECT * FROM process_run_steps
WHERE run_id = :rid AND is_deleted = false AND status = 'pending'
ORDER BY sort_order LIMIT 1
"""), {"rid": run_id})
next_step = result.first()
if next_step:
next_step = dict(next_step._mapping)
# Get the full run for project_id
run_repo = BaseRepository("process_runs", db)
run = await run_repo.get(run_id)
await _generate_tasks(db, run, [next_step], "all_at_once")
# Auto-complete run if all steps done
result = await db.execute(text("""
SELECT count(*) FILTER (WHERE status != 'completed') as pending
FROM process_run_steps
WHERE run_id = :rid AND is_deleted = false
"""), {"rid": run_id})
pending = result.scalar()
if pending == 0:
run_repo = BaseRepository("process_runs", db)
await run_repo.update(run_id, {
"status": "completed",
"completed_at": now,
})
return RedirectResponse(url=f"/processes/runs/{run_id}", status_code=303)
@router.post("/runs/{run_id}/steps/{step_id}/uncomplete")
async def uncomplete_step(
run_id: str,
step_id: str,
db: AsyncSession = Depends(get_db),
):
"""Undo step completion."""
step_repo = BaseRepository("process_run_steps", db)
await step_repo.update(step_id, {
"status": "pending",
"completed_at": None,
})
# If run was completed, reopen it
run_repo = BaseRepository("process_runs", db)
run = await run_repo.get(run_id)
if run and run["status"] == "completed":
await run_repo.update(run_id, {
"status": "in_progress",
"completed_at": None,
})
return RedirectResponse(url=f"/processes/runs/{run_id}", status_code=303)
@router.post("/runs/{run_id}/complete")
async def complete_run(run_id: str, db: AsyncSession = Depends(get_db)):
"""Mark entire run as complete."""
now = datetime.now(timezone.utc)
run_repo = BaseRepository("process_runs", db)
await run_repo.update(run_id, {
"status": "completed",
"completed_at": now,
})
# Mark all pending steps as completed too
await db.execute(text("""
UPDATE process_run_steps
SET status = 'completed', completed_at = :now, updated_at = :now
WHERE run_id = :rid AND status != 'completed' AND is_deleted = false
"""), {"rid": run_id, "now": now})
return RedirectResponse(url=f"/processes/runs/{run_id}", status_code=303)
@router.post("/runs/{run_id}/delete")
async def delete_run(run_id: str, db: AsyncSession = Depends(get_db)):
# Get process_id before deleting for redirect
run_repo = BaseRepository("process_runs", db)
run = await run_repo.get(run_id)
await run_repo.soft_delete(run_id)
if run:
return RedirectResponse(url=f"/processes/{run['process_id']}", status_code=303)
return RedirectResponse(url="/processes/runs", status_code=303)

403
routers/projects.py Normal file
View File

@@ -0,0 +1,403 @@
"""Projects: organizational unit within domain/area hierarchy."""
from fastapi import APIRouter, Request, Form, Depends
from fastapi.templating import Jinja2Templates
from fastapi.responses import RedirectResponse, JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from typing import Optional
from 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("/api/by-domain")
async def api_projects_by_domain(
domain_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
"""JSON API: return projects filtered by domain_id for dynamic dropdowns."""
if domain_id:
result = await db.execute(text("""
SELECT id, name FROM projects
WHERE is_deleted = false AND (domain_id = :did OR domain_id IS NULL)
ORDER BY name
"""), {"did": domain_id})
else:
result = await db.execute(text("""
SELECT id, name FROM projects
WHERE is_deleted = false ORDER BY name
"""))
projects = [{"id": str(r.id), "name": r.name} for r in result]
return JSONResponse(content=projects)
@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 (always needed for progress bar)
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]
# 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)
# Tab-specific data
notes = []
links = []
tab_data = []
all_contacts = []
all_meetings = []
if tab == "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]
elif tab == "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]
elif tab == "files":
result = await db.execute(text("""
SELECT f.* FROM files f
JOIN file_mappings fm ON fm.file_id = f.id
WHERE fm.context_type = 'project' AND fm.context_id = :pid AND f.is_deleted = false
ORDER BY f.created_at DESC
"""), {"pid": project_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "lists":
result = await db.execute(text("""
SELECT l.*,
(SELECT count(*) FROM list_items li WHERE li.list_id = l.id AND li.is_deleted = false) as item_count
FROM lists l
WHERE l.project_id = :pid AND l.is_deleted = false
ORDER BY l.sort_order, l.created_at DESC
"""), {"pid": project_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "decisions":
result = await db.execute(text("""
SELECT d.* FROM decisions d
JOIN decision_projects dp ON dp.decision_id = d.id
WHERE dp.project_id = :pid AND d.is_deleted = false
ORDER BY d.created_at DESC
"""), {"pid": project_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "meetings":
result = await db.execute(text("""
SELECT m.*, pm.created_at as linked_at
FROM meetings m
JOIN project_meetings pm ON pm.meeting_id = m.id
WHERE pm.project_id = :pid AND m.is_deleted = false
ORDER BY m.meeting_date DESC, m.start_at DESC NULLS LAST
"""), {"pid": project_id})
tab_data = [dict(r._mapping) for r in result]
result = await db.execute(text("""
SELECT id, title, meeting_date FROM meetings
WHERE is_deleted = false ORDER BY meeting_date DESC LIMIT 50
"""))
all_meetings = [dict(r._mapping) for r in result]
elif tab == "contacts":
result = await db.execute(text("""
SELECT c.*, cp.role, cp.created_at as linked_at
FROM contacts c
JOIN contact_projects cp ON cp.contact_id = c.id
WHERE cp.project_id = :pid AND c.is_deleted = false
ORDER BY c.first_name
"""), {"pid": project_id})
tab_data = [dict(r._mapping) for r in result]
result = await db.execute(text("""
SELECT id, first_name, last_name FROM contacts
WHERE is_deleted = false ORDER BY first_name
"""))
all_contacts = [dict(r._mapping) for r in result]
# Tab counts
counts = {}
for count_tab, count_sql in [
("notes", "SELECT count(*) FROM notes WHERE project_id = :pid AND is_deleted = false"),
("links", "SELECT count(*) FROM links WHERE project_id = :pid AND is_deleted = false"),
("files", "SELECT count(*) FROM files f JOIN file_mappings fm ON fm.file_id = f.id WHERE fm.context_type = 'project' AND fm.context_id = :pid AND f.is_deleted = false"),
("lists", "SELECT count(*) FROM lists WHERE project_id = :pid AND is_deleted = false"),
("decisions", "SELECT count(*) FROM decisions d JOIN decision_projects dp ON dp.decision_id = d.id WHERE dp.project_id = :pid AND d.is_deleted = false"),
("meetings", "SELECT count(*) FROM meetings m JOIN project_meetings pm ON pm.meeting_id = m.id WHERE pm.project_id = :pid AND m.is_deleted = false"),
("contacts", "SELECT count(*) FROM contacts c JOIN contact_projects cp ON cp.contact_id = c.id WHERE cp.project_id = :pid AND c.is_deleted = false"),
]:
result = await db.execute(text(count_sql), {"pid": project_id})
counts[count_tab] = result.scalar() or 0
return templates.TemplateResponse("project_detail.html", {
"request": request, "sidebar": sidebar, "item": item,
"domain": domain, "area": area,
"tasks": tasks, "notes": notes, "links": links,
"tab_data": tab_data, "all_contacts": all_contacts,
"all_meetings": all_meetings, "counts": counts,
"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)
# ---- Meeting linking ----
@router.post("/{project_id}/meetings/add")
async def add_meeting(
project_id: str,
meeting_id: str = Form(...),
db: AsyncSession = Depends(get_db),
):
await db.execute(text("""
INSERT INTO project_meetings (project_id, meeting_id)
VALUES (:pid, :mid) ON CONFLICT DO NOTHING
"""), {"pid": project_id, "mid": meeting_id})
return RedirectResponse(url=f"/projects/{project_id}?tab=meetings", status_code=303)
@router.post("/{project_id}/meetings/{meeting_id}/remove")
async def remove_meeting(
project_id: str, meeting_id: str,
db: AsyncSession = Depends(get_db),
):
await db.execute(text(
"DELETE FROM project_meetings WHERE project_id = :pid AND meeting_id = :mid"
), {"pid": project_id, "mid": meeting_id})
return RedirectResponse(url=f"/projects/{project_id}?tab=meetings", status_code=303)
# ---- Contact linking ----
@router.post("/{project_id}/contacts/add")
async def add_contact(
project_id: str,
contact_id: str = Form(...),
role: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
await db.execute(text("""
INSERT INTO contact_projects (contact_id, project_id, role)
VALUES (:cid, :pid, :role) ON CONFLICT DO NOTHING
"""), {"cid": contact_id, "pid": project_id, "role": role if role and role.strip() else None})
return RedirectResponse(url=f"/projects/{project_id}?tab=contacts", status_code=303)
@router.post("/{project_id}/contacts/{contact_id}/remove")
async def remove_contact(
project_id: str, contact_id: str,
db: AsyncSession = Depends(get_db),
):
await db.execute(text(
"DELETE FROM contact_projects WHERE contact_id = :cid AND project_id = :pid"
), {"cid": contact_id, "pid": project_id})
return RedirectResponse(url=f"/projects/{project_id}?tab=contacts", status_code=303)

237
routers/search.py Normal file
View File

@@ -0,0 +1,237 @@
"""Global search: Cmd/K modal, tsvector full-text search across all entities."""
import re
from fastapi import APIRouter, Request, Depends, Query
from fastapi.templating import Jinja2Templates
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from typing import Optional
from core.database import get_db
from core.sidebar import get_sidebar_data
router = APIRouter(prefix="/search", tags=["search"])
templates = Jinja2Templates(directory="templates")
def build_prefix_tsquery(query: str) -> Optional[str]:
"""Build a prefix-matching tsquery string from user input.
'Sys Admin' -> 'Sys:* & Admin:*'
Returns None if no valid terms.
"""
terms = query.strip().split()
# Keep only alphanumeric terms >= 2 chars
clean = [re.sub(r'[^\w]', '', t) for t in terms]
clean = [t for t in clean if len(t) >= 2]
if not clean:
return None
return " & ".join(f"{t}:*" for t in clean)
# Entity search configs
# Each has: type, label, table, name_col, joins, extra_cols, url, icon
SEARCH_ENTITIES = [
{
"type": "tasks", "label": "Tasks", "table": "tasks", "alias": "t",
"name_col": "t.title", "status_col": "t.status",
"joins": "LEFT JOIN domains d ON t.domain_id = d.id LEFT JOIN projects p ON t.project_id = p.id",
"domain_col": "d.name", "project_col": "p.name",
"url": "/tasks/{id}", "icon": "task",
},
{
"type": "projects", "label": "Projects", "table": "projects", "alias": "p",
"name_col": "p.name", "status_col": "p.status",
"joins": "LEFT JOIN domains d ON p.domain_id = d.id",
"domain_col": "d.name", "project_col": "NULL",
"url": "/projects/{id}", "icon": "project",
},
{
"type": "notes", "label": "Notes", "table": "notes", "alias": "n",
"name_col": "n.title", "status_col": "NULL",
"joins": "LEFT JOIN domains d ON n.domain_id = d.id LEFT JOIN projects p ON n.project_id = p.id",
"domain_col": "d.name", "project_col": "p.name",
"url": "/notes/{id}", "icon": "note",
},
{
"type": "contacts", "label": "Contacts", "table": "contacts", "alias": "c",
"name_col": "(c.first_name || ' ' || coalesce(c.last_name, ''))", "status_col": "NULL",
"joins": "",
"domain_col": "c.company", "project_col": "NULL",
"url": "/contacts/{id}", "icon": "contact",
},
{
"type": "links", "label": "Links", "table": "links", "alias": "l",
"name_col": "l.label", "status_col": "NULL",
"joins": "LEFT JOIN domains d ON l.domain_id = d.id LEFT JOIN projects p ON l.project_id = p.id",
"domain_col": "d.name", "project_col": "p.name",
"url": "/links", "icon": "link",
},
{
"type": "lists", "label": "Lists", "table": "lists", "alias": "l",
"name_col": "l.name", "status_col": "NULL",
"joins": "LEFT JOIN domains d ON l.domain_id = d.id LEFT JOIN projects p ON l.project_id = p.id",
"domain_col": "d.name", "project_col": "p.name",
"url": "/lists/{id}", "icon": "list",
},
{
"type": "meetings", "label": "Meetings", "table": "meetings", "alias": "m",
"name_col": "m.title", "status_col": "m.status",
"joins": "",
"domain_col": "NULL", "project_col": "NULL",
"url": "/meetings/{id}", "icon": "meeting",
},
{
"type": "decisions", "label": "Decisions", "table": "decisions", "alias": "d",
"name_col": "d.title", "status_col": "d.status",
"joins": "",
"domain_col": "NULL", "project_col": "NULL",
"url": "/decisions/{id}", "icon": "decision",
},
{
"type": "processes", "label": "Processes", "table": "processes", "alias": "p",
"name_col": "p.name", "status_col": "p.status",
"joins": "",
"domain_col": "p.category", "project_col": "NULL",
"url": "/processes/{id}", "icon": "process",
},
{
"type": "appointments", "label": "Appointments", "table": "appointments", "alias": "a",
"name_col": "a.title", "status_col": "NULL",
"joins": "",
"domain_col": "a.location", "project_col": "NULL",
"url": "/appointments/{id}", "icon": "appointment",
},
]
async def _search_entity(entity: dict, q: str, tsquery_str: str, limit: int, db: AsyncSession) -> list[dict]:
"""Search a single entity using prefix tsquery, with ILIKE fallback."""
a = entity["alias"]
results = []
seen_ids = set()
# 1. tsvector prefix search
if tsquery_str:
sql = f"""
SELECT {a}.id, {entity['name_col']} as name, {entity['status_col']} as status,
{entity['domain_col']} as domain_name, {entity['project_col']} as project_name,
ts_rank({a}.search_vector, to_tsquery('english', :tsq)) as rank
FROM {entity['table']} {a}
{entity['joins']}
WHERE {a}.is_deleted = false AND {a}.search_vector @@ to_tsquery('english', :tsq)
ORDER BY rank DESC LIMIT :lim
"""
try:
result = await db.execute(text(sql), {"tsq": tsquery_str, "lim": limit})
for r in result:
row = dict(r._mapping)
results.append(row)
seen_ids.add(str(row["id"]))
except Exception:
pass
# 2. ILIKE fallback if < 3 tsvector results
if len(results) < 3:
ilike_param = f"%{q.strip()}%"
remaining = limit - len(results)
if remaining > 0:
# Build exclusion for already-found IDs
exclude_sql = ""
params = {"ilike_q": ilike_param, "lim2": remaining}
if seen_ids:
id_placeholders = ", ".join(f":ex_{i}" for i in range(len(seen_ids)))
exclude_sql = f"AND {a}.id NOT IN ({id_placeholders})"
for i, sid in enumerate(seen_ids):
params[f"ex_{i}"] = sid
sql2 = f"""
SELECT {a}.id, {entity['name_col']} as name, {entity['status_col']} as status,
{entity['domain_col']} as domain_name, {entity['project_col']} as project_name,
0.0 as rank
FROM {entity['table']} {a}
{entity['joins']}
WHERE {a}.is_deleted = false AND {entity['name_col']} ILIKE :ilike_q {exclude_sql}
ORDER BY {entity['name_col']} LIMIT :lim2
"""
try:
result = await db.execute(text(sql2), params)
for r in result:
results.append(dict(r._mapping))
except Exception:
pass
return results
@router.get("/api")
async def search_api(
q: str = Query("", min_length=1),
entity_type: Optional[str] = None,
limit: int = Query(5, ge=1, le=20),
db: AsyncSession = Depends(get_db),
):
"""JSON search endpoint for the Cmd/K modal."""
if not q or not q.strip() or len(q.strip()) < 2:
return JSONResponse({"results": [], "query": q})
tsquery_str = build_prefix_tsquery(q)
results = []
entities = SEARCH_ENTITIES
if entity_type:
entities = [e for e in entities if e["type"] == entity_type]
for entity in entities:
rows = await _search_entity(entity, q, tsquery_str, limit, db)
for row in rows:
results.append({
"type": entity["type"],
"type_label": entity["label"],
"id": str(row["id"]),
"name": row["name"],
"status": row.get("status"),
"context": " / ".join(filter(None, [row.get("domain_name"), row.get("project_name")])),
"url": entity["url"].format(id=row["id"]),
"rank": float(row.get("rank", 0)),
"icon": entity["icon"],
})
# Sort all results by rank descending
results.sort(key=lambda r: r["rank"], reverse=True)
return JSONResponse({"results": results[:20], "query": q})
@router.get("/")
async def search_page(
request: Request,
q: str = "",
db: AsyncSession = Depends(get_db),
):
"""Full search page (fallback for non-JS)."""
sidebar = await get_sidebar_data(db)
results = []
if q and q.strip() and len(q.strip()) >= 2:
tsquery_str = build_prefix_tsquery(q)
for entity in SEARCH_ENTITIES:
rows = await _search_entity(entity, q, tsquery_str, 10, db)
for row in rows:
results.append({
"type": entity["type"],
"type_label": entity["label"],
"id": str(row["id"]),
"name": row["name"],
"status": row.get("status"),
"context": " / ".join(filter(None, [row.get("domain_name"), row.get("project_name")])),
"url": entity["url"].format(id=row["id"]),
"icon": entity["icon"],
})
return templates.TemplateResponse("search.html", {
"request": request, "sidebar": sidebar,
"results": results, "query": q,
"page_title": "Search", "active_nav": "search",
})

483
routers/tasks.py Normal file
View File

@@ -0,0 +1,483 @@
"""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")
async def get_running_task_id(db: AsyncSession) -> Optional[str]:
"""Get the task_id of the currently running timer, if any."""
result = await db.execute(text(
"SELECT task_id FROM time_entries WHERE end_at IS NULL AND is_deleted = false LIMIT 1"
))
row = result.first()
return str(row.task_id) if row else None
@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]
running_task_id = await get_running_task_id(db)
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,
"running_task_id": running_task_id,
"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,
tab: str = "overview",
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 (always needed for overview tab)
subtasks = []
if tab == "overview":
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]
running_task_id = await get_running_task_id(db)
# Tab-specific data
tab_data = []
all_contacts = []
if tab == "notes":
result = await db.execute(text("""
SELECT * FROM notes WHERE task_id = :tid AND is_deleted = false
ORDER BY updated_at DESC
"""), {"tid": task_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "links":
result = await db.execute(text("""
SELECT * FROM links WHERE task_id = :tid AND is_deleted = false
ORDER BY sort_order, label
"""), {"tid": task_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "files":
result = await db.execute(text("""
SELECT f.* FROM files f
JOIN file_mappings fm ON fm.file_id = f.id
WHERE fm.context_type = 'task' AND fm.context_id = :tid AND f.is_deleted = false
ORDER BY f.created_at DESC
"""), {"tid": task_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "lists":
result = await db.execute(text("""
SELECT l.*,
(SELECT count(*) FROM list_items li WHERE li.list_id = l.id AND li.is_deleted = false) as item_count
FROM lists l
WHERE l.task_id = :tid AND l.is_deleted = false
ORDER BY l.sort_order, l.created_at DESC
"""), {"tid": task_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "decisions":
result = await db.execute(text("""
SELECT * FROM decisions WHERE task_id = :tid AND is_deleted = false
ORDER BY created_at DESC
"""), {"tid": task_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "contacts":
result = await db.execute(text("""
SELECT c.*, ct.role, ct.created_at as linked_at
FROM contacts c
JOIN contact_tasks ct ON ct.contact_id = c.id
WHERE ct.task_id = :tid AND c.is_deleted = false
ORDER BY c.first_name
"""), {"tid": task_id})
tab_data = [dict(r._mapping) for r in result]
# All contacts for add dropdown
result = await db.execute(text("""
SELECT id, first_name, last_name FROM contacts
WHERE is_deleted = false ORDER BY first_name
"""))
all_contacts = [dict(r._mapping) for r in result]
# Tab counts for badges
counts = {}
for count_tab, count_sql in [
("notes", "SELECT count(*) FROM notes WHERE task_id = :tid AND is_deleted = false"),
("links", "SELECT count(*) FROM links WHERE task_id = :tid AND is_deleted = false"),
("files", "SELECT count(*) FROM files f JOIN file_mappings fm ON fm.file_id = f.id WHERE fm.context_type = 'task' AND fm.context_id = :tid AND f.is_deleted = false"),
("lists", "SELECT count(*) FROM lists WHERE task_id = :tid AND is_deleted = false"),
("decisions", "SELECT count(*) FROM decisions WHERE task_id = :tid AND is_deleted = false"),
("contacts", "SELECT count(*) FROM contacts c JOIN contact_tasks ct ON ct.contact_id = c.id WHERE ct.task_id = :tid AND c.is_deleted = false"),
]:
result = await db.execute(text(count_sql), {"tid": task_id})
counts[count_tab] = result.scalar() or 0
# Subtask count for overview badge
result = await db.execute(text(
"SELECT count(*) FROM tasks WHERE parent_id = :tid AND is_deleted = false"
), {"tid": task_id})
counts["overview"] = result.scalar() or 0
return templates.TemplateResponse("task_detail.html", {
"request": request, "sidebar": sidebar, "item": item,
"domain": domain, "project": project, "parent": parent,
"subtasks": subtasks, "tab": tab, "tab_data": tab_data,
"all_contacts": all_contacts, "counts": counts,
"running_task_id": running_task_id,
"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, request: Request, 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)
# ---- Contact linking ----
@router.post("/{task_id}/contacts/add")
async def add_contact(
task_id: str,
contact_id: str = Form(...),
role: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
await db.execute(text("""
INSERT INTO contact_tasks (contact_id, task_id, role)
VALUES (:cid, :tid, :role) ON CONFLICT DO NOTHING
"""), {"cid": contact_id, "tid": task_id, "role": role if role and role.strip() else None})
return RedirectResponse(url=f"/tasks/{task_id}?tab=contacts", status_code=303)
@router.post("/{task_id}/contacts/{contact_id}/remove")
async def remove_contact(
task_id: str, contact_id: str,
db: AsyncSession = Depends(get_db),
):
await db.execute(text(
"DELETE FROM contact_tasks WHERE contact_id = :cid AND task_id = :tid"
), {"cid": contact_id, "tid": task_id})
return RedirectResponse(url=f"/tasks/{task_id}?tab=contacts", status_code=303)

189
routers/time_budgets.py Normal file
View File

@@ -0,0 +1,189 @@
"""Time Budgets: weekly hour allocations per domain with actual vs budgeted comparison."""
from fastapi import APIRouter, Request, Depends, Form
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="/time-budgets", tags=["time_budgets"])
templates = Jinja2Templates(directory="templates")
@router.get("/")
async def list_time_budgets(
request: Request,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
# Get current budget per domain (most recent effective_from <= today)
result = await db.execute(text("""
SELECT DISTINCT ON (tb.domain_id)
tb.*, d.name as domain_name, d.color as domain_color
FROM time_budgets tb
JOIN domains d ON tb.domain_id = d.id
WHERE tb.is_deleted = false AND d.is_deleted = false
AND tb.effective_from <= CURRENT_DATE
ORDER BY tb.domain_id, tb.effective_from DESC
"""))
current_budgets = [dict(r._mapping) for r in result]
# Get actual hours per domain this week (Mon-Sun)
result = await db.execute(text("""
SELECT t.domain_id,
COALESCE(SUM(
CASE
WHEN te.duration_minutes IS NOT NULL THEN te.duration_minutes
WHEN te.end_at IS NOT NULL THEN EXTRACT(EPOCH FROM (te.end_at - te.start_at)) / 60
ELSE 0
END
), 0) / 60.0 as actual_hours
FROM time_entries te
JOIN tasks t ON te.task_id = t.id
WHERE te.is_deleted = false
AND te.start_at >= date_trunc('week', CURRENT_DATE)
AND te.start_at < date_trunc('week', CURRENT_DATE) + INTERVAL '7 days'
AND t.domain_id IS NOT NULL
GROUP BY t.domain_id
"""))
actual_map = {str(r._mapping["domain_id"]): float(r._mapping["actual_hours"]) for r in result}
# Attach actual hours to budgets
for b in current_budgets:
b["actual_hours"] = round(actual_map.get(str(b["domain_id"]), 0), 1)
b["weekly_hours_float"] = float(b["weekly_hours"])
if b["weekly_hours_float"] > 0:
b["pct"] = round(b["actual_hours"] / b["weekly_hours_float"] * 100)
else:
b["pct"] = 0
total_budgeted = sum(float(b["weekly_hours"]) for b in current_budgets)
overcommitted = total_budgeted > 168
# Also get all budgets (including future / historical) for full list
result = await db.execute(text("""
SELECT tb.*, d.name as domain_name, d.color as domain_color
FROM time_budgets tb
JOIN domains d ON tb.domain_id = d.id
WHERE tb.is_deleted = false AND d.is_deleted = false
ORDER BY tb.effective_from DESC, d.name
"""))
all_budgets = [dict(r._mapping) for r in result]
return templates.TemplateResponse("time_budgets.html", {
"request": request,
"sidebar": sidebar,
"current_budgets": current_budgets,
"all_budgets": all_budgets,
"total_budgeted": total_budgeted,
"overcommitted": overcommitted,
"count": len(all_budgets),
"page_title": "Time Budgets",
"active_nav": "time_budgets",
})
@router.get("/create")
async def create_form(
request: Request,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
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]
return templates.TemplateResponse("time_budgets_form.html", {
"request": request,
"sidebar": sidebar,
"budget": None,
"domains": domains,
"page_title": "New Time Budget",
"active_nav": "time_budgets",
})
@router.post("/create")
async def create_budget(
request: Request,
domain_id: str = Form(...),
weekly_hours: str = Form(...),
effective_from: str = Form(...),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("time_budgets", db)
data = {
"domain_id": domain_id,
"weekly_hours": float(weekly_hours),
"effective_from": effective_from,
}
budget = await repo.create(data)
return RedirectResponse(url="/time-budgets", status_code=303)
@router.get("/{budget_id}/edit")
async def edit_form(
request: Request,
budget_id: str,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
repo = BaseRepository("time_budgets", db)
budget = await repo.get(budget_id)
if not budget:
return RedirectResponse(url="/time-budgets", status_code=303)
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]
return templates.TemplateResponse("time_budgets_form.html", {
"request": request,
"sidebar": sidebar,
"budget": budget,
"domains": domains,
"page_title": "Edit Time Budget",
"active_nav": "time_budgets",
})
@router.post("/{budget_id}/edit")
async def update_budget(
request: Request,
budget_id: str,
domain_id: str = Form(...),
weekly_hours: str = Form(...),
effective_from: str = Form(...),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("time_budgets", db)
data = {
"domain_id": domain_id,
"weekly_hours": float(weekly_hours),
"effective_from": effective_from,
}
await repo.update(budget_id, data)
return RedirectResponse(url="/time-budgets", status_code=303)
@router.post("/{budget_id}/delete")
async def delete_budget(
request: Request,
budget_id: str,
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("time_budgets", db)
await repo.soft_delete(budget_id)
return RedirectResponse(url="/time-budgets", status_code=303)

211
routers/time_tracking.py Normal file
View File

@@ -0,0 +1,211 @@
"""Time tracking: start/stop timer per task, manual time entries, time log view."""
from fastapi import APIRouter, Request, Depends, Form, Query
from fastapi.templating import Jinja2Templates
from fastapi.responses import RedirectResponse, JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from typing import Optional
from datetime import datetime, timezone
from core.database import get_db
from core.sidebar import get_sidebar_data
router = APIRouter(prefix="/time", tags=["time"])
templates = Jinja2Templates(directory="templates")
async def get_running_timer(db: AsyncSession) -> dict | None:
"""Get the currently running timer (end_at IS NULL), if any."""
result = await db.execute(text("""
SELECT te.*, t.title as task_title, t.id as task_id,
p.name as project_name, d.name as domain_name
FROM time_entries te
JOIN tasks t ON te.task_id = t.id
LEFT JOIN projects p ON t.project_id = p.id
LEFT JOIN domains d ON t.domain_id = d.id
WHERE te.end_at IS NULL AND te.is_deleted = false
ORDER BY te.start_at DESC
LIMIT 1
"""))
row = result.first()
return dict(row._mapping) if row else None
@router.get("/")
async def time_log(
request: Request,
task_id: Optional[str] = None,
days: int = Query(7, ge=1, le=90),
db: AsyncSession = Depends(get_db),
):
"""Time entries log view."""
sidebar = await get_sidebar_data(db)
running = await get_running_timer(db)
params = {"days": days}
task_filter = ""
if task_id:
task_filter = "AND te.task_id = :task_id"
params["task_id"] = task_id
result = await db.execute(text(f"""
SELECT te.*, t.title as task_title, t.id as task_id,
p.name as project_name, d.name as domain_name, d.color as domain_color
FROM time_entries te
JOIN tasks t ON te.task_id = t.id
LEFT JOIN projects p ON t.project_id = p.id
LEFT JOIN domains d ON t.domain_id = d.id
WHERE te.is_deleted = false
AND te.start_at >= CURRENT_DATE - INTERVAL ':days days'
{task_filter}
ORDER BY te.start_at DESC
LIMIT 200
""".replace(":days days", f"{days} days")), params)
entries = [dict(r._mapping) for r in result]
# Calculate totals
total_minutes = sum(e.get("duration_minutes") or 0 for e in entries)
# Daily breakdown
daily_totals = {}
for e in entries:
if e.get("start_at"):
day = e["start_at"].strftime("%Y-%m-%d")
daily_totals[day] = daily_totals.get(day, 0) + (e.get("duration_minutes") or 0)
return templates.TemplateResponse("time_entries.html", {
"request": request,
"sidebar": sidebar,
"entries": entries,
"running": running,
"total_minutes": total_minutes,
"daily_totals": daily_totals,
"days": days,
"task_id": task_id or "",
"page_title": "Time Log",
"active_nav": "time",
})
@router.post("/start")
async def start_timer(
request: Request,
task_id: str = Form(...),
db: AsyncSession = Depends(get_db),
):
"""Start a timer for a task. Auto-stops any running timer first."""
now = datetime.now(timezone.utc)
# Stop any currently running timer
running = await get_running_timer(db)
if running:
duration = int((now - running["start_at"]).total_seconds() / 60)
await db.execute(text("""
UPDATE time_entries SET end_at = :now, duration_minutes = :dur
WHERE id = :id
"""), {"now": now, "dur": max(duration, 1), "id": str(running["id"])})
# Start new timer
await db.execute(text("""
INSERT INTO time_entries (task_id, start_at, is_deleted, created_at)
VALUES (:task_id, :now, false, :now)
"""), {"task_id": task_id, "now": now})
# Redirect back to where they came from
referer = request.headers.get("referer", "/tasks")
return RedirectResponse(url=referer, status_code=303)
@router.post("/stop")
async def stop_timer(
request: Request,
entry_id: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
"""Stop the running timer (or a specific entry)."""
now = datetime.now(timezone.utc)
if entry_id:
# Stop specific entry
result = await db.execute(text(
"SELECT * FROM time_entries WHERE id = :id AND end_at IS NULL"
), {"id": entry_id})
entry = result.first()
else:
# Stop whatever is running
result = await db.execute(text(
"SELECT * FROM time_entries WHERE end_at IS NULL AND is_deleted = false ORDER BY start_at DESC LIMIT 1"
))
entry = result.first()
if entry:
entry = dict(entry._mapping)
duration = int((now - entry["start_at"]).total_seconds() / 60)
await db.execute(text("""
UPDATE time_entries SET end_at = :now, duration_minutes = :dur
WHERE id = :id
"""), {"now": now, "dur": max(duration, 1), "id": str(entry["id"])})
referer = request.headers.get("referer", "/time")
return RedirectResponse(url=referer, status_code=303)
@router.get("/running")
async def running_timer_api(db: AsyncSession = Depends(get_db)):
"""JSON endpoint for the topbar timer pill to poll."""
running = await get_running_timer(db)
if not running:
return JSONResponse({"running": False})
elapsed_seconds = int((datetime.now(timezone.utc) - running["start_at"]).total_seconds())
return JSONResponse({
"running": True,
"entry_id": str(running["id"]),
"task_id": str(running["task_id"]),
"task_title": running["task_title"],
"project_name": running.get("project_name"),
"start_at": running["start_at"].isoformat(),
"elapsed_seconds": elapsed_seconds,
})
@router.post("/manual")
async def manual_entry(
request: Request,
task_id: str = Form(...),
date: str = Form(...),
duration_minutes: int = Form(...),
notes: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
"""Add a manual time entry (no start/stop, just duration)."""
start_at = datetime.fromisoformat(f"{date}T12:00:00+00:00")
await db.execute(text("""
INSERT INTO time_entries (task_id, start_at, end_at, duration_minutes, notes, is_deleted, created_at)
VALUES (:task_id, :start_at, :start_at, :dur, :notes, false, now())
"""), {
"task_id": task_id,
"start_at": start_at,
"dur": duration_minutes,
"notes": notes or None,
})
referer = request.headers.get("referer", "/time")
return RedirectResponse(url=referer, status_code=303)
@router.post("/{entry_id}/delete")
async def delete_entry(
request: Request,
entry_id: str,
db: AsyncSession = Depends(get_db),
):
# Direct SQL because time_entries has no updated_at column
await db.execute(text("""
UPDATE time_entries SET is_deleted = true, deleted_at = now()
WHERE id = :id AND is_deleted = false
"""), {"id": entry_id})
referer = request.headers.get("referer", "/time")
return RedirectResponse(url=referer, status_code=303)

369
routers/weblinks.py Normal file
View File

@@ -0,0 +1,369 @@
"""Bookmarks: organized folder directory for links."""
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="/weblinks", tags=["bookmarks"])
templates = Jinja2Templates(directory="templates")
async def get_default_folder_id(db: AsyncSession) -> str:
"""Return the Default folder id, creating it if it doesn't exist."""
result = await db.execute(text(
"SELECT id FROM link_folders WHERE name = 'Default' AND is_deleted = false ORDER BY created_at LIMIT 1"
))
row = result.first()
if row:
return str(row[0])
repo = BaseRepository("link_folders", db)
folder = await repo.create({"name": "Default", "sort_order": 0})
return str(folder["id"])
@router.get("/")
async def list_bookmarks(
request: Request,
folder_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
# Get all folders for tree nav
result = await db.execute(text("""
SELECT lf.*, (SELECT count(*) FROM folder_links fl WHERE fl.folder_id = lf.id) as link_count
FROM link_folders lf
WHERE lf.is_deleted = false
ORDER BY lf.sort_order, lf.name
"""))
all_folders = [dict(r._mapping) for r in result]
# Top-level folders and child folders
top_folders = [f for f in all_folders if f.get("parent_id") is None]
child_folder_map = {}
for f in all_folders:
pid = f.get("parent_id")
if pid:
child_folder_map.setdefault(str(pid), []).append(f)
# Current folder info
current_folder = None
if folder_id:
for f in all_folders:
if str(f["id"]) == folder_id:
current_folder = f
break
# Get links (filtered by folder or all)
available_links = []
if folder_id:
result = await db.execute(text("""
SELECT l.* FROM links l
JOIN folder_links fl ON fl.link_id = l.id
WHERE fl.folder_id = :fid AND l.is_deleted = false
ORDER BY fl.sort_order, l.label
"""), {"fid": folder_id})
items = [dict(r._mapping) for r in result]
# Links NOT in this folder (for "add existing" dropdown)
result = await db.execute(text("""
SELECT l.id, l.label FROM links l
WHERE l.is_deleted = false
AND l.id NOT IN (SELECT link_id FROM folder_links WHERE folder_id = :fid)
ORDER BY l.label
"""), {"fid": folder_id})
available_links = [dict(r._mapping) for r in result]
else:
# Show all links
result = await db.execute(text("""
SELECT l.* FROM links l
WHERE l.is_deleted = false
ORDER BY l.sort_order, l.label
"""))
items = [dict(r._mapping) for r in result]
return templates.TemplateResponse("weblinks.html", {
"request": request, "sidebar": sidebar, "items": items,
"top_folders": top_folders, "child_folder_map": child_folder_map,
"current_folder": current_folder,
"current_folder_id": folder_id or "",
"available_links": available_links,
"page_title": "Bookmarks", "active_nav": "bookmarks",
})
@router.get("/create")
async def create_form(
request: Request,
folder_id: Optional[str] = None,
task_id: Optional[str] = None,
meeting_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
result = await db.execute(text(
"SELECT id, name FROM link_folders WHERE is_deleted = false ORDER BY name"
))
folders = [dict(r._mapping) for r in result]
return templates.TemplateResponse("weblink_form.html", {
"request": request, "sidebar": sidebar,
"folders": folders,
"page_title": "New Link", "active_nav": "bookmarks",
"item": None,
"prefill_folder_id": folder_id or "",
"prefill_task_id": task_id or "",
"prefill_meeting_id": meeting_id or "",
})
@router.post("/create")
async def create_link(
request: Request,
label: str = Form(...),
url: str = Form(...),
description: Optional[str] = Form(None),
folder_id: Optional[str] = Form(None),
task_id: Optional[str] = Form(None),
meeting_id: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("links", db)
data = {"label": label, "url": url, "description": description}
if task_id and task_id.strip():
data["task_id"] = task_id
if meeting_id and meeting_id.strip():
data["meeting_id"] = meeting_id
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
link = await repo.create(data)
# Add to folder (default if none specified)
effective_folder = folder_id if folder_id and folder_id.strip() else await get_default_folder_id(db)
await db.execute(text("""
INSERT INTO folder_links (folder_id, link_id)
VALUES (:fid, :lid) ON CONFLICT DO NOTHING
"""), {"fid": effective_folder, "lid": link["id"]})
if task_id and task_id.strip():
return RedirectResponse(url=f"/tasks/{task_id}?tab=links", status_code=303)
if meeting_id and meeting_id.strip():
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=links", status_code=303)
redirect_url = f"/weblinks?folder_id={folder_id}" if folder_id and folder_id.strip() else "/weblinks"
return RedirectResponse(url=redirect_url, 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="/weblinks", status_code=303)
result = await db.execute(text(
"SELECT id, name FROM link_folders WHERE is_deleted = false ORDER BY name"
))
folders = [dict(r._mapping) for r in result]
# Current folder assignment
result = await db.execute(text(
"SELECT folder_id FROM folder_links WHERE link_id = :lid LIMIT 1"
), {"lid": link_id})
row = result.first()
current_folder_id = str(row[0]) if row else ""
return templates.TemplateResponse("weblink_form.html", {
"request": request, "sidebar": sidebar,
"folders": folders,
"page_title": "Edit Link", "active_nav": "bookmarks",
"item": item,
"prefill_folder_id": current_folder_id,
})
@router.post("/{link_id}/edit")
async def update_link(
link_id: str,
label: str = Form(...),
url: str = Form(...),
description: Optional[str] = Form(None),
folder_id: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("links", db)
data = {
"label": label, "url": url,
"description": description if description and description.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(link_id, data)
# Update folder assignment
await db.execute(text("DELETE FROM folder_links WHERE link_id = :lid"), {"lid": link_id})
if folder_id and folder_id.strip():
await db.execute(text("""
INSERT INTO folder_links (folder_id, link_id)
VALUES (:fid, :lid) ON CONFLICT DO NOTHING
"""), {"fid": folder_id, "lid": link_id})
return RedirectResponse(url="/weblinks", 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)
referer = request.headers.get("referer", "/weblinks")
return RedirectResponse(url=referer, status_code=303)
# ---- Reorder links within a folder ----
@router.post("/folders/{folder_id}/reorder")
async def reorder_link(
folder_id: str,
link_id: str = Form(...),
direction: str = Form(...),
db: AsyncSession = Depends(get_db),
):
# Get all folder_links for this folder, ordered by sort_order then created_at
result = await db.execute(text("""
SELECT link_id, sort_order FROM folder_links
WHERE folder_id = :fid
ORDER BY sort_order, created_at
"""), {"fid": folder_id})
rows = [dict(r._mapping) for r in result]
if not rows:
return RedirectResponse(url=f"/weblinks?folder_id={folder_id}", status_code=303)
# Lazy-initialize sort_order if all zeros
all_zero = all(r["sort_order"] == 0 for r in rows)
if all_zero:
for i, r in enumerate(rows):
await db.execute(text("""
UPDATE folder_links SET sort_order = :so
WHERE folder_id = :fid AND link_id = :lid
"""), {"so": (i + 1) * 10, "fid": folder_id, "lid": r["link_id"]})
r["sort_order"] = (i + 1) * 10
# Find target index
idx = None
for i, r in enumerate(rows):
if str(r["link_id"]) == link_id:
idx = i
break
if idx is None:
return RedirectResponse(url=f"/weblinks?folder_id={folder_id}", status_code=303)
# Determine swap partner
if direction == "up" and idx > 0:
swap_idx = idx - 1
elif direction == "down" and idx < len(rows) - 1:
swap_idx = idx + 1
else:
return RedirectResponse(url=f"/weblinks?folder_id={folder_id}", status_code=303)
# Swap sort_order values
so_a, so_b = rows[idx]["sort_order"], rows[swap_idx]["sort_order"]
lid_a, lid_b = rows[idx]["link_id"], rows[swap_idx]["link_id"]
await db.execute(text("""
UPDATE folder_links SET sort_order = :so
WHERE folder_id = :fid AND link_id = :lid
"""), {"so": so_b, "fid": folder_id, "lid": lid_a})
await db.execute(text("""
UPDATE folder_links SET sort_order = :so
WHERE folder_id = :fid AND link_id = :lid
"""), {"so": so_a, "fid": folder_id, "lid": lid_b})
return RedirectResponse(url=f"/weblinks?folder_id={folder_id}", status_code=303)
# ---- Add existing link to folder ----
@router.post("/folders/{folder_id}/add-link")
async def add_link_to_folder(
folder_id: str,
link_id: str = Form(...),
db: AsyncSession = Depends(get_db),
):
# Remove link from any existing folder (single-folder membership)
await db.execute(text("DELETE FROM folder_links WHERE link_id = :lid"), {"lid": link_id})
# Get max sort_order in target folder
result = await db.execute(text("""
SELECT COALESCE(MAX(sort_order), 0) FROM folder_links WHERE folder_id = :fid
"""), {"fid": folder_id})
max_so = result.scalar()
# Insert into target folder at end
await db.execute(text("""
INSERT INTO folder_links (folder_id, link_id, sort_order)
VALUES (:fid, :lid, :so) ON CONFLICT DO NOTHING
"""), {"fid": folder_id, "lid": link_id, "so": max_so + 10})
return RedirectResponse(url=f"/weblinks?folder_id={folder_id}", status_code=303)
# ---- Folders ----
@router.get("/folders/create")
async def create_folder_form(request: Request, db: AsyncSession = Depends(get_db)):
sidebar = await get_sidebar_data(db)
result = await db.execute(text(
"SELECT id, name FROM link_folders WHERE is_deleted = false ORDER BY name"
))
parent_folders = [dict(r._mapping) for r in result]
return templates.TemplateResponse("weblink_folder_form.html", {
"request": request, "sidebar": sidebar,
"parent_folders": parent_folders,
"page_title": "New Folder", "active_nav": "bookmarks",
"item": None,
})
@router.post("/folders/create")
async def create_folder(
request: Request,
name: str = Form(...),
parent_id: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("link_folders", db)
data = {"name": name}
if parent_id and parent_id.strip():
data["parent_id"] = parent_id
await repo.create(data)
return RedirectResponse(url="/weblinks", status_code=303)
@router.post("/folders/{folder_id}/delete")
async def delete_folder(folder_id: str, request: Request, db: AsyncSession = Depends(get_db)):
# Prevent deleting the Default folder
result = await db.execute(text(
"SELECT name FROM link_folders WHERE id = :id"
), {"id": folder_id})
row = result.first()
if row and row[0] == "Default":
return RedirectResponse(url="/weblinks", status_code=303)
repo = BaseRepository("link_folders", db)
await repo.soft_delete(folder_id)
return RedirectResponse(url="/weblinks", status_code=303)

97
setup-claude-code.sh Normal file
View File

@@ -0,0 +1,97 @@
#!/bin/bash
# =============================================================================
# Claude Code Setup for Life OS
# Run as: root on defiant-01
# =============================================================================
set -e
echo "=== Claude Code Setup for Life OS ==="
echo ""
# -----------------------------------------------------------------------------
# Step 1: Install Node.js (required for Claude Code)
# -----------------------------------------------------------------------------
echo "[1/5] Checking Node.js..."
if command -v node &> /dev/null; then
echo " Node.js already installed: $(node --version)"
else
echo " Installing Node.js 20 LTS..."
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y nodejs
echo " Installed: $(node --version)"
fi
echo " npm version: $(npm --version)"
# -----------------------------------------------------------------------------
# Step 2: Install Claude Code
# -----------------------------------------------------------------------------
echo ""
echo "[2/5] Installing Claude Code..."
npm install -g @anthropic-ai/claude-code
echo " Claude Code installed: $(claude --version 2>/dev/null || echo 'run claude to verify')"
# -----------------------------------------------------------------------------
# Step 3: Create project-docs folder structure
# -----------------------------------------------------------------------------
echo ""
echo "[3/5] Creating folder structure..."
mkdir -p /opt/lifeos/dev/project-docs
echo " /opt/lifeos/dev/project-docs/ <- Upload reference docs here"
echo " /opt/lifeos/dev/CLAUDE.md <- Will be placed here"
# -----------------------------------------------------------------------------
# Step 4: Place CLAUDE.md (if uploaded to project-docs already)
# -----------------------------------------------------------------------------
echo ""
echo "[4/5] Checking for CLAUDE.md..."
if [ -f /opt/lifeos/dev/project-docs/CLAUDE.md ]; then
cp /opt/lifeos/dev/project-docs/CLAUDE.md /opt/lifeos/dev/CLAUDE.md
echo " CLAUDE.md copied to /opt/lifeos/dev/CLAUDE.md"
else
echo " CLAUDE.md not found in project-docs yet."
echo " Upload it, then run:"
echo " cp /opt/lifeos/dev/project-docs/CLAUDE.md /opt/lifeos/dev/CLAUDE.md"
fi
# -----------------------------------------------------------------------------
# Step 5: Summary
# -----------------------------------------------------------------------------
echo ""
echo "[5/5] Summary"
echo ""
echo " Folder structure:"
echo " /opt/lifeos/dev/"
echo " CLAUDE.md <- Claude Code reads this automatically"
echo " project-docs/ <- Reference documents"
echo " lifeos-architecture.docx"
echo " lifeos-development-status-convo4.md"
echo " lifeos-development-status-test1.md"
echo " ... (all project reference files)"
echo " main.py"
echo " core/"
echo " routers/"
echo " templates/"
echo " static/"
echo " tests/"
echo ""
echo "=== Next Steps ==="
echo ""
echo " 1. Upload project docs from your Windows machine:"
echo " scp C:\\lifeos-dev\\ubuntu\\* root@46.225.166.142:/opt/lifeos/dev/project-docs/"
echo ""
echo " 2. Upload CLAUDE.md separately:"
echo " scp C:\\lifeos-dev\\ubuntu\\CLAUDE.md root@46.225.166.142:/opt/lifeos/dev/CLAUDE.md"
echo ""
echo " 3. First run of Claude Code:"
echo " cd /opt/lifeos/dev && claude"
echo ""
echo " 4. You'll be prompted to authenticate with your Anthropic account."
echo " Follow the browser/URL instructions."
echo ""
echo " 5. Add CLAUDE.md to .gitignore (optional - keeps it out of the app repo):"
echo " echo 'CLAUDE.md' >> /opt/lifeos/dev/.gitignore"
echo " echo 'project-docs/' >> /opt/lifeos/dev/.gitignore"
echo ""

18321
smoke-results.txt Normal file

File diff suppressed because it is too large Load Diff

252
static/app.js Normal file
View File

@@ -0,0 +1,252 @@
/* 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>'
);
// ---- Search Modal ----
let searchDebounce = null;
function openSearch() {
const modal = document.getElementById('search-modal');
if (!modal) return;
modal.classList.remove('hidden');
const input = document.getElementById('search-input');
input.value = '';
input.focus();
document.getElementById('search-results').innerHTML = '';
}
function closeSearch() {
const modal = document.getElementById('search-modal');
if (modal) modal.classList.add('hidden');
}
document.addEventListener('keydown', (e) => {
// Cmd/Ctrl + K opens search
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
openSearch();
}
// Escape closes search
if (e.key === 'Escape') {
closeSearch();
}
});
// Live search with debounce
document.addEventListener('DOMContentLoaded', () => {
const input = document.getElementById('search-input');
if (!input) return;
input.addEventListener('input', () => {
clearTimeout(searchDebounce);
const q = input.value.trim();
if (q.length < 1) {
document.getElementById('search-results').innerHTML = '';
return;
}
searchDebounce = setTimeout(() => doSearch(q), 200);
});
// Navigate results with arrow keys
input.addEventListener('keydown', (e) => {
const results = document.querySelectorAll('.search-result-item');
const active = document.querySelector('.search-result-item.active');
let idx = Array.from(results).indexOf(active);
if (e.key === 'ArrowDown') {
e.preventDefault();
if (active) active.classList.remove('active');
idx = (idx + 1) % results.length;
results[idx]?.classList.add('active');
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (active) active.classList.remove('active');
idx = idx <= 0 ? results.length - 1 : idx - 1;
results[idx]?.classList.add('active');
} else if (e.key === 'Enter') {
e.preventDefault();
const activeItem = document.querySelector('.search-result-item.active');
if (activeItem) {
window.location.href = activeItem.dataset.url;
}
}
});
});
async function doSearch(query) {
const container = document.getElementById('search-results');
try {
const resp = await fetch(`/search/api?q=${encodeURIComponent(query)}&limit=8`);
const data = await resp.json();
if (!data.results || data.results.length === 0) {
container.innerHTML = '<div class="search-empty">No results found</div>';
return;
}
// Group by type
const grouped = {};
data.results.forEach(r => {
if (!grouped[r.type_label]) grouped[r.type_label] = [];
grouped[r.type_label].push(r);
});
let html = '';
for (const [label, items] of Object.entries(grouped)) {
html += `<div class="search-group-label">${label}</div>`;
items.forEach((item, i) => {
const isFirst = i === 0 && label === Object.keys(grouped)[0];
html += `<a href="${item.url}" class="search-result-item ${isFirst ? 'active' : ''}" data-url="${item.url}">
<span class="search-result-name">${escHtml(item.name)}</span>
${item.context ? `<span class="search-result-context">${escHtml(item.context)}</span>` : ''}
${item.status ? `<span class="status-badge status-${item.status}">${item.status.replace('_', ' ')}</span>` : ''}
</a>`;
});
}
container.innerHTML = html;
} catch (err) {
container.innerHTML = '<div class="search-empty">Search error</div>';
}
}
function escHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// ---- Timer Pill (topbar running timer) ----
let timerStartAt = null;
let timerInterval = null;
function formatElapsed(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) {
return h + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0');
}
return m + ':' + String(s).padStart(2, '0');
}
function updateTimerPill() {
if (!timerStartAt) return;
const now = new Date();
const secs = Math.floor((now - timerStartAt) / 1000);
const el = document.getElementById('timer-pill-elapsed');
if (el) el.textContent = formatElapsed(secs);
}
async function pollTimer() {
try {
const resp = await fetch('/time/running');
const data = await resp.json();
const pill = document.getElementById('timer-pill');
if (!pill) return;
if (data.running) {
pill.classList.remove('hidden');
timerStartAt = new Date(data.start_at);
const taskEl = document.getElementById('timer-pill-task');
if (taskEl) {
taskEl.textContent = data.task_title;
taskEl.href = '/tasks/' + data.task_id;
}
updateTimerPill();
// Start 1s interval if not already running
if (!timerInterval) {
timerInterval = setInterval(updateTimerPill, 1000);
}
} else {
pill.classList.add('hidden');
timerStartAt = null;
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
}
} catch (err) {
// Silently ignore polling errors
}
}
// Poll on load, then every 30s
document.addEventListener('DOMContentLoaded', () => {
pollTimer();
setInterval(pollTimer, 30000);
});
// ---- Mobile More Panel ----
document.addEventListener('DOMContentLoaded', function() {
var btn = document.getElementById('mobMoreBtn');
var panel = document.getElementById('mobMore');
var overlay = document.getElementById('mobOverlay');
if (!btn || !panel || !overlay) return;
btn.addEventListener('click', function() {
panel.classList.toggle('open');
overlay.classList.toggle('open');
});
overlay.addEventListener('click', function() {
panel.classList.remove('open');
overlay.classList.remove('open');
});
});

1733
static/style.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,106 @@
{% extends "base.html" %}
{% block content %}
<div class="breadcrumb">
<a href="/appointments">Appointments</a>
<span class="sep">/</span>
<span>{{ appointment.title }}</span>
</div>
<div class="detail-header">
<div style="display: flex; align-items: flex-start; justify-content: space-between; gap: 16px;">
<div>
<h1 class="detail-title">{{ appointment.title }}</h1>
<div class="detail-meta">
{% if appointment.all_day %}
<span class="detail-meta-item">
<span class="status-badge status-active">All Day</span>
</span>
{% endif %}
<span class="detail-meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
{% if appointment.all_day %}
{{ appointment.start_at.strftime('%A, %B %-d, %Y') }}
{% if appointment.end_at and appointment.end_at.strftime('%Y-%m-%d') != appointment.start_at.strftime('%Y-%m-%d') %}
&ndash; {{ appointment.end_at.strftime('%A, %B %-d, %Y') }}
{% endif %}
{% else %}
{{ appointment.start_at.strftime('%A, %B %-d, %Y at %-I:%M %p') }}
{% if appointment.end_at %}
&ndash; {{ appointment.end_at.strftime('%-I:%M %p') }}
{% endif %}
{% endif %}
</span>
{% if appointment.location %}
<span class="detail-meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
{{ appointment.location }}
</span>
{% endif %}
{% if appointment.recurrence %}
<span class="detail-meta-item">
<span class="row-tag">{{ appointment.recurrence }}</span>
</span>
{% endif %}
</div>
</div>
<div style="display: flex; gap: 8px; flex-shrink: 0;">
<a href="/appointments/{{ appointment.id }}/edit" class="btn btn-secondary btn-sm">Edit</a>
<form method="POST" action="/appointments/{{ appointment.id }}/delete" data-confirm="Delete this appointment?">
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form>
</div>
</div>
</div>
{% if appointment.description %}
<div class="card mb-4">
<div class="card-title mb-2">Description</div>
<div class="detail-body">{{ appointment.description }}</div>
</div>
{% endif %}
{% if appointment.tags %}
<div style="display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px;">
{% for tag in appointment.tags %}
<span class="row-tag">{{ tag }}</span>
{% endfor %}
</div>
{% endif %}
<!-- Attendees -->
<div class="card">
<div class="card-header">
<span class="card-title">Attendees ({{ contacts | length }})</span>
</div>
{% if contacts %}
{% for c in contacts %}
<div class="list-row">
<div class="row-title">
<a href="/contacts/{{ c.id }}">{{ c.first_name }} {{ c.last_name or '' }}</a>
</div>
{% if c.company %}
<span class="row-meta">{{ c.company }}</span>
{% endif %}
{% if c.email %}
<span class="row-meta">{{ c.email }}</span>
{% endif %}
{% if c.role %}
<span class="row-tag">{{ c.role }}</span>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="empty-state" style="padding: 24px;">
<div class="text-muted text-sm">No attendees added</div>
</div>
{% endif %}
</div>
<div class="text-muted text-xs mt-3">
Created {{ appointment.created_at.strftime('%B %-d, %Y') }}
{% if appointment.updated_at and appointment.updated_at != appointment.created_at %}
&middot; Updated {{ appointment.updated_at.strftime('%B %-d, %Y') }}
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,130 @@
{% extends "base.html" %}
{% block content %}
<div class="breadcrumb">
<a href="/appointments">Appointments</a>
<span class="sep">/</span>
<span>{{ "Edit" if appointment else "New Appointment" }}</span>
</div>
<div class="page-header">
<h1 class="page-title">{{ "Edit Appointment" if appointment else "New Appointment" }}</h1>
</div>
<div class="card" style="max-width: 720px;">
<form method="POST" action="{{ '/appointments/' ~ appointment.id ~ '/edit' if appointment else '/appointments/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="{{ appointment.title if appointment else '' }}">
</div>
<div class="form-group">
<label class="form-label">Start Date *</label>
<input type="date" name="start_date" class="form-input" required
value="{{ appointment.start_at.strftime('%Y-%m-%d') if appointment and appointment.start_at else '' }}">
</div>
<div class="form-group" id="start-time-group">
<label class="form-label">Start Time</label>
<input type="time" name="start_time" class="form-input" id="start-time-input"
value="{{ appointment.start_at.strftime('%H:%M') if appointment and appointment.start_at and not appointment.all_day else '' }}">
</div>
<div class="form-group">
<label class="form-label">End Date</label>
<input type="date" name="end_date" class="form-input"
value="{{ appointment.end_at.strftime('%Y-%m-%d') if appointment and appointment.end_at else '' }}">
</div>
<div class="form-group" id="end-time-group">
<label class="form-label">End Time</label>
<input type="time" name="end_time" class="form-input" id="end-time-input"
value="{{ appointment.end_at.strftime('%H:%M') if appointment and appointment.end_at and not appointment.all_day else '' }}">
</div>
<div class="form-group">
<label class="form-label" style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" name="all_day" id="all-day-check"
{{ 'checked' if appointment and appointment.all_day else '' }}
onchange="toggleAllDay(this.checked)"
style="width: 16px; height: 16px;">
All Day Event
</label>
</div>
<div class="form-group">
<label class="form-label">Recurrence</label>
<select name="recurrence" class="form-select">
<option value="">None</option>
<option value="daily" {{ 'selected' if appointment and appointment.recurrence == 'daily' }}>Daily</option>
<option value="weekly" {{ 'selected' if appointment and appointment.recurrence == 'weekly' }}>Weekly</option>
<option value="monthly" {{ 'selected' if appointment and appointment.recurrence == 'monthly' }}>Monthly</option>
</select>
</div>
<div class="form-group full-width">
<label class="form-label">Location</label>
<input type="text" name="location" class="form-input" placeholder="Address, room, or video link"
value="{{ appointment.location if appointment and appointment.location else '' }}">
</div>
<div class="form-group full-width">
<label class="form-label">Description</label>
<textarea name="description" class="form-textarea" rows="3" placeholder="Notes about this appointment...">{{ appointment.description if appointment and appointment.description else '' }}</textarea>
</div>
<div class="form-group full-width">
<label class="form-label">Tags</label>
<input type="text" name="tags" class="form-input" placeholder="Comma-separated tags"
value="{{ appointment.tags | join(', ') if appointment and appointment.tags else '' }}">
</div>
{% if contacts %}
<div class="form-group full-width">
<label class="form-label">Attendees</label>
<div style="display: flex; flex-wrap: wrap; gap: 8px; padding: 8px; background: var(--surface2); border-radius: var(--radius); border: 1px solid var(--border); max-height: 200px; overflow-y: auto;">
{% for c in contacts %}
<label style="display: flex; align-items: center; gap: 6px; padding: 4px 10px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: 0.85rem; cursor: pointer; white-space: nowrap;">
<input type="checkbox" name="contact_ids" value="{{ c.id }}"
{{ 'checked' if c.id|string in selected_contacts else '' }}
style="width: 14px; height: 14px;">
{{ c.first_name }} {{ c.last_name or '' }}
{% if c.company %}<span style="color: var(--muted); font-size: 0.78rem;">({{ c.company }})</span>{% endif %}
</label>
{% endfor %}
</div>
</div>
{% endif %}
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ "Update Appointment" if appointment else "Create Appointment" }}</button>
<a href="{{ '/appointments/' ~ appointment.id if appointment else '/appointments' }}" class="btn btn-secondary">Cancel</a>
</div>
</div>
</form>
</div>
<script>
function toggleAllDay(checked) {
const startTime = document.getElementById('start-time-input');
const endTime = document.getElementById('end-time-input');
if (checked) {
startTime.disabled = true;
startTime.style.opacity = '0.4';
endTime.disabled = true;
endTime.style.opacity = '0.4';
} else {
startTime.disabled = false;
startTime.style.opacity = '1';
endTime.disabled = false;
endTime.style.opacity = '1';
}
}
// Init on load
document.addEventListener('DOMContentLoaded', () => {
const cb = document.getElementById('all-day-check');
if (cb && cb.checked) toggleAllDay(true);
});
</script>
{% endblock %}

View File

@@ -0,0 +1,72 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<div>
<h1 class="page-title">Appointments <span class="page-count">({{ count }})</span></h1>
</div>
<a href="/appointments/new" class="btn btn-primary">+ New Appointment</a>
</div>
<!-- Filters -->
<div class="filters-bar">
<a href="/appointments?timeframe=upcoming" class="btn {{ 'btn-primary' if timeframe == 'upcoming' else 'btn-secondary' }} btn-sm">Upcoming</a>
<a href="/appointments?timeframe=past" class="btn {{ 'btn-primary' if timeframe == 'past' else 'btn-secondary' }} btn-sm">Past</a>
<a href="/appointments?timeframe=all" class="btn {{ 'btn-primary' if timeframe == 'all' else 'btn-secondary' }} btn-sm">All</a>
</div>
{% if appointments %}
<div class="card">
{% set current_date = namespace(value='') %}
{% for appt in appointments %}
{% set appt_date = appt.start_at.strftime('%A, %B %-d, %Y') if appt.start_at else 'No Date' %}
{% if appt_date != current_date.value %}
{% if not loop.first %}</div>{% endif %}
<div class="date-group-label" style="padding: 12px 12px 4px; font-size: 0.78rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; {% if not loop.first %}border-top: 1px solid var(--border); margin-top: 4px;{% endif %}">
{{ appt_date }}
</div>
<div>
{% set current_date.value = appt_date %}
{% endif %}
<div class="list-row">
<div style="flex-shrink: 0; min-width: 60px;">
{% if appt.all_day %}
<span class="status-badge status-active" style="font-size: 0.72rem;">All Day</span>
{% elif appt.start_at %}
<span style="font-size: 0.85rem; font-weight: 600; color: var(--text);">{{ appt.start_at.strftime('%-I:%M %p') }}</span>
{% endif %}
</div>
<div class="row-title">
<a href="/appointments/{{ appt.id }}">{{ appt.title }}</a>
</div>
{% if appt.location %}
<span class="row-meta">{{ appt.location }}</span>
{% endif %}
{% if appt.recurrence %}
<span class="row-tag">{{ appt.recurrence }}</span>
{% endif %}
{% if appt.contact_count and appt.contact_count > 0 %}
<span class="row-meta">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: -2px;"><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>
{{ appt.contact_count }}
</span>
{% endif %}
<div class="row-actions">
<a href="/appointments/{{ appt.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
<form method="POST" action="/appointments/{{ appt.id }}/delete" data-confirm="Delete this appointment?">
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red);">Delete</button>
</form>
</div>
</div>
{% endfor %}
</div>
</div>
{% else %}
<div class="empty-state">
<div class="empty-state-icon">📅</div>
<div class="empty-state-text">No appointments {{ 'upcoming' if timeframe == 'upcoming' else 'found' }}</div>
<a href="/appointments/new" class="btn btn-primary">Schedule an Appointment</a>
</div>
{% endif %}
{% endblock %}

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 %}

241
templates/base.html Normal file
View File

@@ -0,0 +1,241 @@
<!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">
<style>
/* Critical mob-nav positioning - inline to guarantee it loads */
.mob-nav{display:none}
@media(max-width:768px){
.mob-nav{display:flex!important;position:fixed!important;bottom:0!important;left:0!important;right:0!important;height:56px!important;background:var(--surface)!important;border-top:1px solid var(--border)!important;z-index:9999!important;flex-direction:row!important;flex-wrap:nowrap!important;align-items:center!important;justify-content:space-around!important;padding:0!important;margin:0!important;overflow:hidden!important}
.mob-overlay{display:none!important;position:fixed!important;top:0!important;left:0!important;right:0!important;bottom:0!important;background:rgba(0,0,0,.5)!important;z-index:10000!important}
.mob-overlay.open{display:block!important}
.mob-more{display:none!important;position:fixed!important;bottom:56px!important;left:0!important;right:0!important;background:var(--surface)!important;border-top:1px solid var(--border)!important;border-radius:12px 12px 0 0!important;z-index:10001!important;padding:16px!important;grid-template-columns:repeat(3,1fr)!important;gap:8px!important}
.mob-more.open{display:grid!important}
}
</style>
</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="/lists" class="nav-item {{ 'active' if active_nav == 'lists' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
Lists
</a>
<a href="/processes" class="nav-item {{ 'active' if active_nav == 'processes' }}">
<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="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
Processes
</a>
<a href="/meetings" class="nav-item {{ 'active' if active_nav == 'meetings' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
Meetings
</a>
<a href="/appointments" class="nav-item {{ 'active' if active_nav == 'appointments' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
Appointments
</a>
<a href="/calendar" class="nav-item {{ 'active' if active_nav == 'calendar' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><rect x="7" y="14" width="3" height="3" rx="0.5"/><rect x="14" y="14" width="3" height="3" rx="0.5"/></svg>
Calendar
</a>
<a href="/eisenhower" class="nav-item {{ 'active' if active_nav == 'eisenhower' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="8" height="8" rx="1"/><rect x="13" y="3" width="8" height="8" rx="1"/><rect x="3" y="13" width="8" height="8" rx="1"/><rect x="13" y="13" width="8" height="8" rx="1"/></svg>
Eisenhower
</a>
<a href="/decisions" class="nav-item {{ 'active' if active_nav == 'decisions' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"/><path d="M9 12l2 2 4-4"/></svg>
Decisions
</a>
<a href="/files" class="nav-item {{ 'active' if active_nav == 'files' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>
Files
</a>
<a href="/weblinks" class="nav-item {{ 'active' if active_nav == 'bookmarks' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Bookmarks
</a>
<a href="/time" class="nav-item {{ 'active' if active_nav == 'time' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
Time Log
</a>
<a href="/time-budgets" class="nav-item {{ 'active' if active_nav == 'time_budgets' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/><path d="M16 3l2 2-2 2"/></svg>
Time Budgets
</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="/history" class="nav-item {{ 'active' if active_nav == 'history' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
History
</a>
<a href="/admin/trash" class="nav-item {{ 'active' if active_nav == 'trash' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
Trash
</a>
<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>
<!-- Timer Pill (populated by JS) -->
<div id="timer-pill" class="timer-pill hidden">
<div class="timer-pill-dot"></div>
<a id="timer-pill-task" class="timer-pill-task" href="#"></a>
<span id="timer-pill-elapsed" class="timer-pill-elapsed">0:00</span>
<form method="POST" action="/time/stop" style="display:inline;">
<button type="submit" class="timer-pill-stop" title="Stop timer">&#9632;</button>
</form>
</div>
<form class="quick-capture-form" method="POST" action="/capture/add">
<input type="hidden" name="redirect_to" value="{{ request.url.path }}{% if request.url.query %}?{{ request.url.query }}{% endif %}">
<span class="quick-capture-icon">+</span>
<input type="text" name="raw_text" class="quick-capture-input" placeholder="Quick capture..." autocomplete="off" required>
<button type="submit" class="quick-capture-submit" title="Capture">+</button>
</form>
<button class="search-trigger" onclick="openSearch()" title="Search (Cmd/K)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<span>Search...</span>
<kbd>&#8984;K</kbd>
</button>
</header>
<div class="page-content">
{% block content %}{% endblock %}
</div>
</main>
</div>
<!-- Search Modal -->
<div id="search-modal" class="search-modal hidden">
<div class="search-modal-backdrop" onclick="closeSearch()"></div>
<div class="search-modal-content">
<div class="search-modal-input-wrap">
<svg class="search-modal-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="search-input" class="search-modal-input" placeholder="Search tasks, projects, notes..." autocomplete="off">
<kbd class="search-modal-esc" onclick="closeSearch()">Esc</kbd>
</div>
<div id="search-results" class="search-results"></div>
</div>
</div>
<script src="/static/app.js"></script>
<div class="mob-nav" id="mobNav" style="position:fixed;bottom:0;left:0;right:0;z-index:9999">
<a href="/" class="mob-nav__item {% if request.url.path == '/' %}mob-nav__item--active{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/></svg>
<span>Home</span>
</a>
<a href="/focus" class="mob-nav__item {% if '/focus' in request.url.path %}mob-nav__item--active{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" 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>
<span>Focus</span>
</a>
<a href="/tasks" class="mob-nav__item {% if '/tasks' in request.url.path %}mob-nav__item--active{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>
<span>Tasks</span>
</a>
<a href="/capture" class="mob-nav__item {% if '/capture' in request.url.path %}mob-nav__item--active{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
<span>Capture</span>
</a>
<button class="mob-nav__item" id="mobMoreBtn" type="button">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
<span>More</span>
</button>
</div>
<div class="mob-overlay" id="mobOverlay"></div>
<div class="mob-more" id="mobMore">
<a href="/calendar" class="mob-more__item"><span>Calendar</span></a>
<a href="/notes" class="mob-more__item"><span>Notes</span></a>
<a href="/meetings" class="mob-more__item"><span>Meetings</span></a>
<a href="/decisions" class="mob-more__item"><span>Decisions</span></a>
<a href="/contacts" class="mob-more__item"><span>Contacts</span></a>
<a href="/processes" class="mob-more__item"><span>Processes</span></a>
<a href="/weblinks" class="mob-more__item"><span>Bookmarks</span></a>
<a href="/admin" class="mob-more__item"><span>Admin</span></a>
</div>
</body>
</html>

58
templates/calendar.html Normal file
View File

@@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<div>
<h1 class="page-title">Calendar</h1>
</div>
</div>
<!-- Month Navigation -->
<div class="cal-nav">
<a href="/calendar?year={{ prev_year }}&month={{ prev_month }}" class="btn btn-secondary btn-sm">&larr; Prev</a>
<span class="cal-month-label">{{ month_name }} {{ year }}</span>
<a href="/calendar?year={{ next_year }}&month={{ next_month }}" class="btn btn-secondary btn-sm">Next &rarr;</a>
{% if year != today.year or month != today.month %}
<a href="/calendar" class="btn btn-ghost btn-sm">Today</a>
{% endif %}
</div>
<!-- Legend -->
<div class="cal-legend">
<span class="cal-legend-item"><span class="cal-dot cal-dot-appointment"></span> Appointment</span>
<span class="cal-legend-item"><span class="cal-dot cal-dot-meeting"></span> Meeting</span>
<span class="cal-legend-item"><span class="cal-dot cal-dot-task"></span> Task</span>
</div>
<!-- Calendar Grid -->
<div class="cal-grid">
<div class="cal-header-row">
<div class="cal-header-cell">Sun</div>
<div class="cal-header-cell">Mon</div>
<div class="cal-header-cell">Tue</div>
<div class="cal-header-cell">Wed</div>
<div class="cal-header-cell">Thu</div>
<div class="cal-header-cell">Fri</div>
<div class="cal-header-cell">Sat</div>
</div>
{% for week in weeks %}
<div class="cal-week-row">
{% for day in week %}
<div class="cal-day-cell {{ 'cal-day-empty' if day == 0 }} {{ 'cal-day-today' if day > 0 and today.year == year and today.month == month and today.day == day }}">
{% if day > 0 %}
<div class="cal-day-num">{{ day }}</div>
<div class="cal-events">
{% for event in days_map.get(day, []) %}
<a href="{{ event.url }}" class="cal-event cal-event-{{ event.type }}" title="{{ event.title }}">
{% if event.time %}<span class="cal-event-time">{{ event.time }}</span>{% endif %}
<span class="cal-event-title">{{ event.title }}</span>
</a>
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endfor %}
</div>
{% endblock %}

110
templates/capture.html Normal file
View File

@@ -0,0 +1,110 @@
{% 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 capture-form-card">
<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 to capture..." required></textarea>
<details class="mt-2">
<summary class="text-sm text-muted" style="cursor:pointer">Context (optional)</summary>
<div class="form-grid mt-2" style="grid-template-columns:1fr 1fr">
<div class="form-group">
<label class="form-label">Project</label>
<select name="project_id" class="form-select">
<option value="">None</option>
{% for d in sidebar.domain_tree %}
<optgroup label="{{ d.name }}">
{% for a in d.areas %}{% for p in a.projects %}
<option value="{{ p.id }}">{{ p.name }}</option>
{% endfor %}{% endfor %}
{% for p in d.standalone_projects %}
<option value="{{ p.id }}">{{ p.name }}</option>
{% endfor %}
</optgroup>
{% 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 d in sidebar.domain_tree %}
{% for a in d.areas %}
<option value="{{ a.id }}">{{ d.name }} / {{ a.name }}</option>
{% endfor %}
{% endfor %}
</select>
</div>
</div>
</details>
<div class="mt-2"><button type="submit" class="btn btn-primary btn-capture">Capture</button></div>
</form>
</div>
<!-- Filter tabs -->
<div class="tab-strip mb-3">
<a href="/capture?show=inbox" class="tab-item {{ 'active' if show == 'inbox' or show not in ('processed', 'all') }}">Inbox</a>
<a href="/capture?show=processed" class="tab-item {{ 'active' if show == 'processed' }}">Processed</a>
<a href="/capture?show=all" class="tab-item {{ 'active' if show == 'all' }}">All</a>
</div>
{% if items %}
{% for item in items %}
{# Batch header #}
{% if item._batch_first and item.import_batch_id %}
<div class="flex items-center justify-between mb-2 mt-3" style="padding:6px 12px;background:var(--surface2);border-radius:var(--radius-sm)">
<span class="text-xs text-muted">Batch &middot; {{ batches[item.import_batch_id|string] }} items</span>
<form action="/capture/batch/{{ item.import_batch_id }}/undo" method="post" style="display:inline" data-confirm="Delete all items in this batch?">
<button class="btn btn-ghost btn-xs" style="color:var(--red)">Undo batch</button>
</form>
</div>
{% endif %}
<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 item.processed %}
<div class="capture-actions">
{% if item.converted_to_type and item.converted_to_type != 'dismissed' %}
<span class="row-tag">{{ item.converted_to_type|replace('_', ' ') }}</span>
{% if item.converted_to_id %}
{% if item.converted_to_type == 'list_item' and item.list_id %}
<a href="/lists/{{ item.list_id }}" class="btn btn-ghost btn-xs">View &rarr;</a>
{% elif item.converted_to_type == 'link' %}
<a href="/links" class="btn btn-ghost btn-xs">View &rarr;</a>
{% elif item.converted_to_type in ('task', 'note', 'project', 'contact', 'decision') %}
<a href="/{{ item.converted_to_type }}s/{{ item.converted_to_id }}" class="btn btn-ghost btn-xs">View &rarr;</a>
{% endif %}
{% endif %}
{% elif item.converted_to_type == 'dismissed' %}
<span class="row-tag" style="color:var(--muted)">dismissed</span>
{% endif %}
</div>
{% else %}
<div class="capture-actions">
<select onchange="if(this.value) window.location.href=this.value" class="filter-select" style="font-size:0.75rem;padding:3px 6px">
<option value="">Convert...</option>
{% for key, label in convert_types.items() %}
<option value="/capture/{{ item.id }}/convert/{{ key }}">{{ label }}</option>
{% endfor %}
</select>
<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">
{% if show == 'processed' %}No processed items{% elif show == 'all' %}No capture items{% else %}Inbox is empty{% endif %}
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,153 @@
{% extends "base.html" %}
{% block content %}
<div class="breadcrumb">
<a href="/capture">Capture</a> <span class="sep">/</span> Convert to {{ type_label }}
</div>
<div class="page-header">
<h1 class="page-title">Convert to {{ type_label }}</h1>
</div>
<div class="card">
<!-- Original text -->
<div class="form-group mb-3">
<label class="form-label">Original Text</label>
<div style="padding:10px 12px;background:var(--surface2);border-radius:var(--radius);font-size:0.92rem;color:var(--text-secondary)">{{ item.raw_text }}</div>
</div>
<form action="/capture/{{ item.id }}/to-{{ convert_type }}" method="post">
<div class="form-grid">
{% if convert_type == 'task' %}
<div class="form-group full-width">
<label class="form-label">Title</label>
<input type="text" name="title" class="form-input" value="{{ item.raw_text }}" required>
</div>
<div class="form-group">
<label class="form-label">Domain *</label>
<select name="domain_id" class="form-select" required>
{% for d in sidebar.domain_tree %}
<option value="{{ d.id }}">{{ 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 d in sidebar.domain_tree %}
<optgroup label="{{ d.name }}">
{% for a in d.areas %}{% for p in a.projects %}
<option value="{{ p.id }}" {{ 'selected' if item.project_id and p.id|string == item.project_id|string }}>{{ p.name }}</option>
{% endfor %}{% endfor %}
{% for p in d.standalone_projects %}
<option value="{{ p.id }}" {{ 'selected' if item.project_id and p.id|string == item.project_id|string }}>{{ p.name }}</option>
{% endfor %}
</optgroup>
{% endfor %}
</select>
</div>
<div class="form-group">
<label class="form-label">Priority</label>
<select name="priority" class="form-select">
<option value="1">1 - Critical</option>
<option value="2">2 - High</option>
<option value="3" selected>3 - Normal</option>
<option value="4">4 - Low</option>
</select>
</div>
{% elif convert_type == 'note' %}
<div class="form-group full-width">
<label class="form-label">Title</label>
<input type="text" name="title" class="form-input" value="{{ item.raw_text[:100] }}" required>
</div>
<div class="form-group">
<label class="form-label">Domain</label>
<select name="domain_id" class="form-select">
<option value="">None</option>
{% for d in sidebar.domain_tree %}
<option value="{{ d.id }}">{{ 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 d in sidebar.domain_tree %}
<optgroup label="{{ d.name }}">
{% for a in d.areas %}{% for p in a.projects %}
<option value="{{ p.id }}" {{ 'selected' if item.project_id and p.id|string == item.project_id|string }}>{{ p.name }}</option>
{% endfor %}{% endfor %}
{% for p in d.standalone_projects %}
<option value="{{ p.id }}" {{ 'selected' if item.project_id and p.id|string == item.project_id|string }}>{{ p.name }}</option>
{% endfor %}
</optgroup>
{% endfor %}
</select>
</div>
{% elif convert_type == 'project' %}
<div class="form-group full-width">
<label class="form-label">Project Name</label>
<input type="text" name="name" class="form-input" value="{{ item.raw_text }}" required>
</div>
<div class="form-group">
<label class="form-label">Domain *</label>
<select name="domain_id" class="form-select" required>
{% for d in sidebar.domain_tree %}
<option value="{{ d.id }}">{{ d.name }}</option>
{% endfor %}
</select>
</div>
{% elif convert_type == 'list_item' %}
<div class="form-group full-width">
<label class="form-label">Content</label>
<input type="text" name="content" class="form-input" value="{{ item.raw_text }}" required>
</div>
<div class="form-group">
<label class="form-label">Add to List *</label>
<select name="list_id" class="form-select" required>
{% for lst in all_lists %}
<option value="{{ lst.id }}" {{ 'selected' if item.list_id and lst.id|string == item.list_id|string }}>{{ lst.name }}</option>
{% endfor %}
</select>
</div>
{% elif convert_type == 'contact' %}
<div class="form-group">
<label class="form-label">First Name *</label>
<input type="text" name="first_name" class="form-input" value="{{ first_name }}" required>
</div>
<div class="form-group">
<label class="form-label">Last Name</label>
<input type="text" name="last_name" class="form-input" value="{{ last_name }}">
</div>
{% elif convert_type == 'decision' %}
<div class="form-group full-width">
<label class="form-label">Decision Title</label>
<input type="text" name="title" class="form-input" value="{{ item.raw_text }}" required>
</div>
{% elif convert_type == 'link' %}
<div class="form-group full-width">
<label class="form-label">Label</label>
<input type="text" name="label" class="form-input" value="{{ prefill_label }}" required>
</div>
<div class="form-group full-width">
<label class="form-label">URL</label>
<input type="url" name="url" class="form-input" value="{{ prefill_url }}" placeholder="https://" required>
</div>
{% endif %}
<div class="form-actions">
<button type="submit" class="btn btn-primary">Convert to {{ type_label }}</button>
<a href="/capture" class="btn btn-secondary">Cancel</a>
</div>
</div>
</form>
</div>
{% 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 %}

140
templates/dashboard.html Normal file
View File

@@ -0,0 +1,140 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Dashboard</h1>
</div>
<!-- Stats -->
<div class="dashboard-grid mb-4">
<a href="/tasks/?status=open" class="stat-card-link">
<div class="stat-card">
<div class="stat-value">{{ stats.open_tasks or 0 }}</div>
<div class="stat-label">Open Tasks</div>
</div>
</a>
<a href="/tasks/?status=in_progress" class="stat-card-link">
<div class="stat-card">
<div class="stat-value">{{ stats.in_progress or 0 }}</div>
<div class="stat-label">In Progress</div>
</div>
</a>
<a href="/tasks/?status=done" class="stat-card-link">
<div class="stat-card">
<div class="stat-value">{{ stats.done_this_week or 0 }}</div>
<div class="stat-label">Done This Week</div>
</div>
</a>
<a href="/focus/" class="stat-card-link">
<div class="stat-card">
<div class="stat-value">{{ focus_items|length }}</div>
<div class="stat-label">Today's Focus</div>
</div>
</a>
</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>
<!-- Project Deadlines -->
{% if overdue_projects or upcoming_projects %}
<div class="card mt-4">
<div class="card-header">
<h2 class="card-title">Project Deadlines</h2>
<a href="/projects/" class="btn btn-ghost btn-sm">All Projects</a>
</div>
{% if overdue_projects %}
<div class="text-xs text-muted mb-2" style="font-weight:600; color: var(--red);">OVERDUE</div>
{% for p in overdue_projects %}
<div class="list-row">
<span class="priority-dot priority-{{ p.priority }}"></span>
<span class="row-title"><a href="/projects/{{ p.id }}">{{ p.name }}</a></span>
{% if p.domain_name %}
<span class="row-meta">{{ p.domain_name }}</span>
{% endif %}
<div class="project-progress-mini">
<div class="project-progress-bar" style="width: {{ ((p.done_count / p.task_count * 100) if p.task_count else 0)|int }}%"></div>
</div>
<span class="row-meta" style="min-width: 32px; text-align: right; font-size: 0.72rem;">{{ p.done_count }}/{{ p.task_count }}</span>
<span class="row-meta overdue">{{ p.target_date }}</span>
</div>
{% endfor %}
{% endif %}
{% if upcoming_projects %}
<div class="text-xs text-muted mb-2 {{ 'mt-3' if overdue_projects }}" style="font-weight:600;">NEXT 30 DAYS</div>
{% for p in upcoming_projects %}
<div class="list-row">
<span class="priority-dot priority-{{ p.priority }}"></span>
<span class="row-title"><a href="/projects/{{ p.id }}">{{ p.name }}</a></span>
{% if p.domain_name %}
<span class="row-meta">{{ p.domain_name }}</span>
{% endif %}
<div class="project-progress-mini">
<div class="project-progress-bar" style="width: {{ ((p.done_count / p.task_count * 100) if p.task_count else 0)|int }}%"></div>
</div>
<span class="row-meta" style="min-width: 32px; text-align: right; font-size: 0.72rem;">{{ p.done_count }}/{{ p.task_count }}</span>
<span class="row-meta">{{ p.target_date }}</span>
</div>
{% endfor %}
{% endif %}
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,55 @@
{% extends "base.html" %}
{% block content %}
<div class="breadcrumb">
<a href="/decisions">Decisions</a>
<span class="sep">/</span>
<span>{{ item.title }}</span>
</div>
<div class="detail-header">
<h1 class="detail-title">{{ item.title }}</h1>
<div class="flex gap-2">
<a href="/decisions/{{ item.id }}/edit" class="btn btn-secondary btn-sm">Edit</a>
<form action="/decisions/{{ item.id }}/delete" method="post" data-confirm="Delete this decision?" style="display:inline">
<button type="submit" 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 }}</span>
<span class="row-tag impact-{{ item.impact }}">{{ item.impact }} impact</span>
{% if item.decided_at %}<span class="detail-meta-item">Decided: {{ item.decided_at }}</span>{% endif %}
{% if meeting %}<span class="detail-meta-item">Meeting: <a href="/meetings/{{ meeting.id }}">{{ meeting.title }}</a></span>{% endif %}
{% if item.tags %}
<div class="mt-1">{% for tag in item.tags %}<span class="row-tag">{{ tag }}</span>{% endfor %}</div>
{% endif %}
</div>
{% if superseded_by %}
<div class="card mt-3" style="border-left: 3px solid var(--amber);">
<div style="padding: 12px 16px;">
<strong style="color: var(--amber);">Superseded by:</strong>
<a href="/decisions/{{ superseded_by.id }}">{{ superseded_by.title }}</a>
</div>
</div>
{% endif %}
{% if item.rationale %}
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Rationale</h3></div>
<div class="detail-body" style="padding: 12px 16px;">{{ item.rationale }}</div>
</div>
{% endif %}
{% if supersedes %}
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Supersedes</h3></div>
{% for dec in supersedes %}
<div class="list-row">
<span class="row-title"><a href="/decisions/{{ dec.id }}">{{ dec.title }}</a></span>
</div>
{% endfor %}
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,87 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">{{ page_title }}</h1>
</div>
<div class="card">
<form method="post" action="{{ '/decisions/' ~ item.id ~ '/edit' if item else '/decisions/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 '' }}" placeholder="Summary of the decision...">
</div>
<div class="form-group">
<label class="form-label">Status</label>
<select name="status" class="form-select">
<option value="proposed" {{ 'selected' if (item and item.status == 'proposed') or not item }}>Proposed</option>
<option value="accepted" {{ 'selected' if item and item.status == 'accepted' }}>Accepted</option>
<option value="rejected" {{ 'selected' if item and item.status == 'rejected' }}>Rejected</option>
<option value="superseded" {{ 'selected' if item and item.status == 'superseded' }}>Superseded</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Impact</label>
<select name="impact" class="form-select">
<option value="low" {{ 'selected' if item and item.impact == 'low' }}>Low</option>
<option value="medium" {{ 'selected' if (item and item.impact == 'medium') or not item }}>Medium</option>
<option value="high" {{ 'selected' if item and item.impact == 'high' }}>High</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Decision Date</label>
<input type="date" name="decided_at" class="form-input"
value="{{ item.decided_at if item and item.decided_at else '' }}">
</div>
<div class="form-group">
<label class="form-label">Meeting</label>
<select name="meeting_id" class="form-select">
<option value="">None</option>
{% for m in meetings %}
<option value="{{ m.id }}"
{{ 'selected' if (item and item.meeting_id and item.meeting_id|string == m.id|string) or (not item and prefill_meeting_id == m.id|string) }}>
{{ m.title }} ({{ m.meeting_date }})
</option>
{% endfor %}
</select>
</div>
{% if item and other_decisions is defined %}
<div class="form-group">
<label class="form-label">Superseded By</label>
<select name="superseded_by_id" class="form-select">
<option value="">None</option>
{% for d in other_decisions %}
<option value="{{ d.id }}" {{ 'selected' if item.superseded_by_id and item.superseded_by_id|string == d.id|string }}>
{{ d.title }}
</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="form-group full-width">
<label class="form-label">Rationale</label>
<textarea name="rationale" class="form-textarea" rows="6" placeholder="Why was this decided? What alternatives were considered?">{{ item.rationale if item and item.rationale else '' }}</textarea>
</div>
<div class="form-group">
<label class="form-label">Tags</label>
<input type="text" name="tags" class="form-input" placeholder="tag1, tag2, ..."
value="{{ item.tags|join(', ') if item and item.tags else '' }}">
</div>
</div>
{% if prefill_task_id is defined and prefill_task_id %}<input type="hidden" name="task_id" value="{{ prefill_task_id }}">{% endif %}
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'Save Changes' if item else 'Record Decision' }}</button>
<a href="{{ '/tasks/' ~ prefill_task_id ~ '?tab=decisions' if prefill_task_id is defined and prefill_task_id else ('/decisions/' ~ item.id if item else '/decisions') }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
{% endblock %}

53
templates/decisions.html Normal file
View File

@@ -0,0 +1,53 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Decisions<span class="page-count">{{ items|length }}</span></h1>
<a href="/decisions/create" class="btn btn-primary">+ New Decision</a>
</div>
<form class="filters-bar" method="get" action="/decisions">
<select name="status" class="filter-select" onchange="this.form.submit()">
<option value="">All Statuses</option>
<option value="proposed" {{ 'selected' if current_status == 'proposed' }}>Proposed</option>
<option value="accepted" {{ 'selected' if current_status == 'accepted' }}>Accepted</option>
<option value="rejected" {{ 'selected' if current_status == 'rejected' }}>Rejected</option>
<option value="superseded" {{ 'selected' if current_status == 'superseded' }}>Superseded</option>
</select>
<select name="impact" class="filter-select" onchange="this.form.submit()">
<option value="">All Impact</option>
<option value="high" {{ 'selected' if current_impact == 'high' }}>High</option>
<option value="medium" {{ 'selected' if current_impact == 'medium' }}>Medium</option>
<option value="low" {{ 'selected' if current_impact == 'low' }}>Low</option>
</select>
</form>
{% if items %}
<div class="card mt-3">
{% for item in items %}
<div class="list-row">
<span class="row-title"><a href="/decisions/{{ item.id }}">{{ item.title }}</a></span>
<span class="status-badge status-{{ item.status }}">{{ item.status }}</span>
<span class="row-tag impact-{{ item.impact }}">{{ item.impact }}</span>
{% if item.decided_at %}
<span class="row-meta">{{ item.decided_at }}</span>
{% endif %}
{% if item.meeting_title %}
<span class="row-meta">{{ item.meeting_title }}</span>
{% endif %}
<div class="row-actions">
<a href="/decisions/{{ item.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
<form action="/decisions/{{ item.id }}/delete" method="post" data-confirm="Delete this decision?" 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 mt-3">
<div class="empty-state-icon">&#9878;</div>
<div class="empty-state-text">No decisions recorded yet</div>
<a href="/decisions/create" class="btn btn-primary">Record First Decision</a>
</div>
{% endif %}
{% 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 %}

184
templates/eisenhower.html Normal file
View File

@@ -0,0 +1,184 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1>Eisenhower Matrix</h1>
<span class="text-muted">{{ total }} open tasks classified by priority &amp; urgency</span>
</div>
<!-- Filters -->
<form class="filters-bar" method="get" action="/eisenhower" id="eis-filters">
<select name="domain_id" class="filter-select" id="eis-domain" 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" id="eis-project" 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="status" class="filter-select" 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>
</select>
<select name="context" class="filter-select" onchange="this.form.submit()">
<option value="">All Contexts</option>
{% for ct in context_types %}
<option value="{{ ct.value }}" {{ 'selected' if current_context == ct.value }}>{{ ct.label }}</option>
{% endfor %}
</select>
</form>
<div class="eisenhower-grid">
<!-- Axis labels -->
<div class="eisenhower-y-label">
<span>Important</span>
</div>
<!-- Q1: Urgent + Important -->
<div class="eisenhower-quadrant eisenhower-q1">
<div class="eisenhower-quadrant-header">
<h3>Do First</h3>
<span class="eisenhower-quadrant-subtitle">Urgent &amp; Important</span>
<span class="badge badge-red">{{ counts.do_first }}</span>
</div>
<div class="eisenhower-task-list">
{% for task in quadrants.do_first %}
<a href="/tasks/{{ task.id }}" class="eisenhower-task">
<span class="priority-dot priority-{{ task.priority }}"></span>
<span class="eisenhower-task-title">{{ task.title }}</span>
{% if task.due_date %}
<span class="eisenhower-task-due {% if task.due_date < today %}overdue{% endif %}">
{{ task.due_date.strftime('%b %-d') }}
</span>
{% endif %}
{% if task.project_name %}
<span class="eisenhower-task-project">{{ task.project_name }}</span>
{% endif %}
</a>
{% else %}
<div class="eisenhower-empty">No tasks</div>
{% endfor %}
</div>
</div>
<!-- Q2: Not Urgent + Important -->
<div class="eisenhower-quadrant eisenhower-q2">
<div class="eisenhower-quadrant-header">
<h3>Schedule</h3>
<span class="eisenhower-quadrant-subtitle">Not Urgent &amp; Important</span>
<span class="badge badge-accent">{{ counts.schedule }}</span>
</div>
<div class="eisenhower-task-list">
{% for task in quadrants.schedule %}
<a href="/tasks/{{ task.id }}" class="eisenhower-task">
<span class="priority-dot priority-{{ task.priority }}"></span>
<span class="eisenhower-task-title">{{ task.title }}</span>
{% if task.due_date %}
<span class="eisenhower-task-due">{{ task.due_date.strftime('%b %-d') }}</span>
{% endif %}
{% if task.project_name %}
<span class="eisenhower-task-project">{{ task.project_name }}</span>
{% endif %}
</a>
{% else %}
<div class="eisenhower-empty">No tasks</div>
{% endfor %}
</div>
</div>
<!-- Second row y-label -->
<div class="eisenhower-y-label">
<span>Not Important</span>
</div>
<!-- Q3: Urgent + Not Important -->
<div class="eisenhower-quadrant eisenhower-q3">
<div class="eisenhower-quadrant-header">
<h3>Delegate</h3>
<span class="eisenhower-quadrant-subtitle">Urgent &amp; Not Important</span>
<span class="badge badge-amber">{{ counts.delegate }}</span>
</div>
<div class="eisenhower-task-list">
{% for task in quadrants.delegate %}
<a href="/tasks/{{ task.id }}" class="eisenhower-task">
<span class="priority-dot priority-{{ task.priority }}"></span>
<span class="eisenhower-task-title">{{ task.title }}</span>
{% if task.due_date %}
<span class="eisenhower-task-due {% if task.due_date < today %}overdue{% endif %}">
{{ task.due_date.strftime('%b %-d') }}
</span>
{% endif %}
{% if task.project_name %}
<span class="eisenhower-task-project">{{ task.project_name }}</span>
{% endif %}
</a>
{% else %}
<div class="eisenhower-empty">No tasks</div>
{% endfor %}
</div>
</div>
<!-- Q4: Not Urgent + Not Important -->
<div class="eisenhower-quadrant eisenhower-q4">
<div class="eisenhower-quadrant-header">
<h3>Eliminate</h3>
<span class="eisenhower-quadrant-subtitle">Not Urgent &amp; Not Important</span>
<span class="badge badge-muted">{{ counts.eliminate }}</span>
</div>
<div class="eisenhower-task-list">
{% for task in quadrants.eliminate %}
<a href="/tasks/{{ task.id }}" class="eisenhower-task">
<span class="priority-dot priority-{{ task.priority }}"></span>
<span class="eisenhower-task-title">{{ task.title }}</span>
{% if task.due_date %}
<span class="eisenhower-task-due">{{ task.due_date.strftime('%b %-d') }}</span>
{% endif %}
{% if task.project_name %}
<span class="eisenhower-task-project">{{ task.project_name }}</span>
{% endif %}
</a>
{% else %}
<div class="eisenhower-empty">No tasks</div>
{% endfor %}
</div>
</div>
<!-- X-axis labels -->
<div class="eisenhower-x-spacer"></div>
<div class="eisenhower-x-label">Urgent</div>
<div class="eisenhower-x-label">Not Urgent</div>
</div>
<script>
(function() {
var domainSel = document.getElementById('eis-domain');
var projectSel = document.getElementById('eis-project');
var currentProjectId = '{{ current_project_id }}';
var form = document.getElementById('eis-filters');
domainSel.addEventListener('change', function() {
var did = domainSel.value;
if (!did) { form.submit(); return; }
fetch('/projects/api/by-domain?domain_id=' + encodeURIComponent(did))
.then(function(r) { return r.json(); })
.then(function(projects) {
projectSel.innerHTML = '<option value="">All Projects</option>';
projects.forEach(function(p) {
var opt = document.createElement('option');
opt.value = p.id;
opt.textContent = p.name;
if (p.id === currentProjectId) opt.selected = true;
projectSel.appendChild(opt);
});
form.submit();
})
.catch(function() { form.submit(); });
});
})();
</script>
{% endblock %}

View File

@@ -0,0 +1,52 @@
{% extends "base.html" %}
{% block content %}
<div class="breadcrumb">
<a href="/files">Files</a>
<span class="sep">/</span>
{% if folder and folder != '/' %}
<a href="/files?folder={{ folder }}">{{ folder }}</a>
<span class="sep">/</span>
{% endif %}
<span>{{ item.original_filename }}</span>
</div>
<div class="detail-header">
<h1 class="detail-title">{{ item.original_filename }}</h1>
<div class="flex gap-2">
<a href="/files/{{ item.id }}/download" class="btn btn-primary btn-sm">Download</a>
<form action="/files/{{ item.id }}/delete" method="post" data-confirm="Delete this file?" style="display:inline">
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form>
</div>
</div>
<div class="detail-meta mt-2">
<span class="detail-meta-item">Folder: {{ folder }}</span>
{% if item.mime_type %}<span class="detail-meta-item">Type: {{ item.mime_type }}</span>{% endif %}
{% if item.size_bytes %}<span class="detail-meta-item">Size: {{ "%.1f"|format(item.size_bytes / 1024) }} KB</span>{% endif %}
{% if item.description %}<span class="detail-meta-item">{{ item.description }}</span>{% endif %}
{% if item.tags %}
<div class="mt-1">
{% for tag in item.tags %}<span class="row-tag">{{ tag }}</span>{% endfor %}
</div>
{% endif %}
</div>
{% if can_preview %}
<div class="card mt-3" style="padding: 16px;">
{% if item.mime_type and item.mime_type.startswith('image/') %}
<img src="/files/{{ item.id }}/serve" alt="{{ item.original_filename }}" style="max-width: 100%; height: auto; border-radius: var(--radius);">
{% elif item.mime_type == 'application/pdf' %}
<iframe src="/files/{{ item.id }}/serve" style="width: 100%; height: 600px; border: none; border-radius: var(--radius);"></iframe>
{% elif item.mime_type and item.mime_type.startswith('text/') %}
<iframe src="/files/{{ item.id }}/serve" style="width: 100%; height: 400px; border: 1px solid var(--border); border-radius: var(--radius); background: #fff;"></iframe>
{% endif %}
</div>
{% else %}
<div class="empty-state mt-3">
<div class="empty-state-icon">&#128196;</div>
<div class="empty-state-text">Preview not available for this file type</div>
<a href="/files/{{ item.id }}/download" class="btn btn-primary">Download Instead</a>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,52 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Upload File</h1>
</div>
<div class="card">
<form method="post" action="/files/upload" enctype="multipart/form-data">
{% if context_type %}
<input type="hidden" name="context_type" value="{{ context_type }}">
<input type="hidden" name="context_id" value="{{ context_id }}">
{% endif %}
<div class="form-grid">
<div class="form-group full-width">
<label class="form-label">File *</label>
<input type="file" name="file" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label">Folder</label>
<select name="folder" class="form-input">
<option value="">/ (root)</option>
{% for f in folders %}
<option value="{{ f }}" {{ 'selected' if prefill_folder == f }}>{{ f }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label class="form-label">New Folder</label>
<input type="text" name="new_folder" class="form-input" placeholder="Or create new folder...">
</div>
<div class="form-group full-width">
<label class="form-label">Description</label>
<input type="text" name="description" class="form-input" placeholder="Optional description...">
</div>
<div class="form-group">
<label class="form-label">Tags</label>
<input type="text" name="tags" class="form-input" placeholder="tag1, tag2, ...">
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Upload</button>
<a href="/files" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
{% endblock %}

91
templates/files.html Normal file
View File

@@ -0,0 +1,91 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Files<span class="page-count">{{ items|length }}</span></h1>
<div class="flex gap-2">
<form action="/files/sync" method="post" style="display:inline">
<button type="submit" class="btn btn-secondary">Sync Files</button>
</form>
<a href="/files/upload{{ '?folder=' ~ current_folder if current_folder }}{{ '?context_type=' ~ context_type ~ '&context_id=' ~ context_id if context_type }}" class="btn btn-primary">+ Upload File</a>
</div>
</div>
{% if sync_result and (sync_result.added > 0 or sync_result.removed > 0) %}
<div class="flash-message" style="background: var(--accent-soft); border: 1px solid var(--accent); border-radius: var(--radius); padding: 8px 12px; margin-bottom: 16px; color: var(--text); font-size: 0.85rem;">
Synced: {{ sync_result.added }} file{{ 's' if sync_result.added != 1 }} added, {{ sync_result.removed }} removed
</div>
{% endif %}
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 16px;">
<label style="color: var(--muted); font-size: 0.85rem; white-space: nowrap;">Folder:</label>
<select class="form-input" style="max-width: 280px; padding: 6px 10px; font-size: 0.85rem;" onchange="window.location.href=this.value">
<option value="/files" {{ 'selected' if current_folder is none }}>All folders</option>
<option value="/files?folder=" {{ 'selected' if current_folder is not none and current_folder == '' }}>/ (root)</option>
{% for f in folders %}
<option value="/files?folder={{ f }}" {{ 'selected' if current_folder == f }}>{% if '/' in f %}&nbsp;&nbsp;{{ f.split('/')[-1] }}{% else %}{{ f }}{% endif %}</option>
{% endfor %}
</select>
</div>
{% set sort_base = '/files?' ~ ('folder=' ~ current_folder ~ '&' if current_folder is not none else '') %}
{% if items %}
<div class="card" style="overflow-x: auto;">
<table class="data-table" style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="border-bottom: 1px solid var(--border); text-align: left;">
<th style="padding: 10px 12px; font-weight: 600; font-size: 0.8rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em;">
<a href="{{ sort_base }}sort={{ 'path_desc' if current_sort == 'path' else 'path' }}" style="color: var(--muted); text-decoration: none;">
Path {{ '▲' if current_sort == 'path' else ('▼' if current_sort == 'path_desc' else '') }}
</a>
</th>
<th style="padding: 10px 12px; font-weight: 600; font-size: 0.8rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em;">
<a href="{{ sort_base }}sort={{ 'name_desc' if current_sort == 'name' else 'name' }}" style="color: var(--muted); text-decoration: none;">
Name {{ '▲' if current_sort == 'name' else ('▼' if current_sort == 'name_desc' else '') }}
</a>
</th>
<th style="padding: 10px 12px; font-weight: 600; font-size: 0.8rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em;">Type</th>
<th style="padding: 10px 12px; font-weight: 600; font-size: 0.8rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em;">Size</th>
<th style="padding: 10px 12px; font-weight: 600; font-size: 0.8rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em;">
<a href="{{ sort_base }}sort={{ 'date_asc' if current_sort == 'date' else 'date' }}" style="color: var(--muted); text-decoration: none;">
Date {{ '▼' if current_sort == 'date' else ('▲' if current_sort == 'date_asc' else '') }}
</a>
</th>
<th style="padding: 10px 12px;"></th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr style="border-bottom: 1px solid var(--border);">
<td style="padding: 10px 12px; color: var(--muted); font-size: 0.85rem;">{{ item.folder }}</td>
<td style="padding: 10px 12px;">
<a href="/files/{{ item.id }}/preview" style="color: var(--accent);">{{ item.original_filename }}</a>
</td>
<td style="padding: 10px 12px;">
{% if item.mime_type %}<span class="row-tag">{{ item.mime_type.split('/')|last }}</span>{% endif %}
</td>
<td style="padding: 10px 12px; color: var(--muted); font-size: 0.85rem; white-space: nowrap;">
{% if item.size_bytes %}{{ "%.1f"|format(item.size_bytes / 1024) }} KB{% endif %}
</td>
<td style="padding: 10px 12px; color: var(--muted); font-size: 0.85rem; white-space: nowrap;">
{{ item.created_at.strftime('%Y-%m-%d') if item.created_at else '' }}
</td>
<td style="padding: 10px 12px; text-align: right; white-space: nowrap;">
<a href="/files/{{ item.id }}/download" class="btn btn-ghost btn-xs">Download</a>
<form action="/files/{{ item.id }}/delete" method="post" data-confirm="Delete this file?" style="display:inline">
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Del</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<div class="empty-state-icon">&#128193;</div>
<div class="empty-state-text">No files{{ ' in this folder' if current_folder is not none else ' uploaded yet' }}</div>
<a href="/files/upload" class="btn btn-primary">Upload First File</a>
</div>
{% endif %}
{% endblock %}

109
templates/focus.html Normal file
View File

@@ -0,0 +1,109 @@
{% 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 -->
<div class="card mt-4">
<div class="card-header"><h2 class="card-title">Add to Focus</h2></div>
<form class="filters-bar" method="get" action="/focus" id="focus-filters" style="padding: 8px 12px; border-bottom: 1px solid var(--border);">
<input type="hidden" name="focus_date" value="{{ focus_date }}">
<select name="domain_id" class="filter-select" id="focus-domain" 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="area_id" class="filter-select" onchange="this.form.submit()">
<option value="">All Areas</option>
{% for a in areas %}
<option value="{{ a.id }}" {{ 'selected' if current_area_id == a.id|string }}>{{ a.name }}</option>
{% endfor %}
</select>
<select name="project_id" class="filter-select" id="focus-project" 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>
</form>
{% 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>
{% else %}
<div style="padding: 16px; color: var(--muted); font-size: 0.85rem;">No available tasks matching filters</div>
{% endfor %}
</div>
<script>
(function() {
var domainSel = document.getElementById('focus-domain');
var projectSel = document.getElementById('focus-project');
var currentProjectId = '{{ current_project_id }}';
var form = document.getElementById('focus-filters');
domainSel.addEventListener('change', function() {
var did = domainSel.value;
if (!did) { form.submit(); return; }
fetch('/projects/api/by-domain?domain_id=' + encodeURIComponent(did))
.then(function(r) { return r.json(); })
.then(function(projects) {
projectSel.innerHTML = '<option value="">All Projects</option>';
projects.forEach(function(p) {
var opt = document.createElement('option');
opt.value = p.id;
opt.textContent = p.name;
if (p.id === currentProjectId) opt.selected = true;
projectSel.appendChild(opt);
});
form.submit();
})
.catch(function() { form.submit(); });
});
})();
</script>
{% endblock %}

32
templates/history.html Normal file
View File

@@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Change History<span class="page-count">{{ items|length }}</span></h1>
</div>
<form class="filters-bar" method="get" action="/history">
<select name="entity_type" class="filter-select" onchange="this.form.submit()">
<option value="">All Types</option>
{% for t in type_options %}
<option value="{{ t.value }}" {{ 'selected' if current_type == t.value }}>{{ t.label }}</option>
{% endfor %}
</select>
</form>
{% if items %}
<div class="card mt-3">
{% for item in items %}
<div class="list-row">
<span class="row-tag" style="min-width: 70px; text-align: center;">{{ item.type_label }}</span>
<span class="row-title"><a href="{{ item.url }}">{{ item.label }}</a></span>
<span class="status-badge {{ 'status-active' if item.action == 'created' else 'status-open' }}">{{ item.action }}</span>
<span class="row-meta">{{ item.updated_at.strftime('%Y-%m-%d %H:%M') if item.updated_at else '' }}</span>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state mt-3">
<div class="empty-state-text">No recent changes</div>
</div>
{% endif %}
{% endblock %}

21
templates/link_form.html Normal file
View File

@@ -0,0 +1,21 @@
{% 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"><option value="">-- None --</option>{% for d in domains %}<option value="{{ d.id }}" {{ 'selected' if (item and item.domain_id 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">Tags</label><input type="text" name="tags" class="form-input" placeholder="tag1, tag2, ..." value="{{ item.tags|join(', ') if item and item.tags else '' }}"></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>
{% if prefill_task_id is defined and prefill_task_id %}<input type="hidden" name="task_id" value="{{ prefill_task_id }}">{% endif %}
{% if prefill_meeting_id is defined and prefill_meeting_id %}<input type="hidden" name="meeting_id" value="{{ prefill_meeting_id }}">{% endif %}
<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 %}

132
templates/list_detail.html Normal file
View File

@@ -0,0 +1,132 @@
{% extends "base.html" %}
{% block content %}
<!-- Breadcrumb -->
<div class="breadcrumb">
<a href="/lists">Lists</a>
<span class="sep">/</span>
{% if domain %}<span style="color: {{ domain.color or 'var(--accent)' }}">{{ 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.name }}</span>
</div>
<div class="detail-header">
<h1 class="detail-title">{{ item.name }}</h1>
<div class="flex gap-2">
<a href="/lists/{{ item.id }}/edit" class="btn btn-secondary btn-sm">Edit</a>
<form action="/lists/{{ item.id }}/delete" method="post" data-confirm="Delete this list?" style="display:inline">
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form>
</div>
</div>
<div class="detail-meta mt-2">
<span class="detail-meta-item">
<span class="row-tag">{{ item.list_type }}</span>
</span>
{% if item.description %}
<p class="text-secondary mt-1">{{ item.description }}</p>
{% endif %}
{% if item.tags %}
<div class="mt-1">
{% for tag in item.tags %}
<span class="row-tag">{{ tag }}</span>
{% endfor %}
</div>
{% endif %}
</div>
<!-- Add item form -->
<form class="quick-add mt-3" action="/lists/{{ item.id }}/items/add" method="post">
<input type="text" name="content" placeholder="Add item..." required>
<button type="submit" class="btn btn-primary btn-sm">Add</button>
</form>
<!-- List items -->
{% if list_items %}
<div class="card mt-2">
{% for li in list_items %}
<div class="list-row {{ 'completed' if li.completed }}">
{% if item.list_type == 'checklist' %}
<div class="row-check">
<form action="/lists/{{ item.id }}/items/{{ li.id }}/toggle" method="post" style="display:inline">
<input type="checkbox" id="li-{{ li.id }}" {{ 'checked' if li.completed }}
onchange="this.form.submit()">
<label for="li-{{ li.id }}"></label>
</form>
</div>
{% endif %}
<span class="row-title" style="{{ 'text-decoration: line-through; opacity: 0.6;' if li.completed }}">
{{ li.content }}
</span>
<div class="row-actions">
<form action="/lists/{{ item.id }}/items/{{ li.id }}/delete" method="post" style="display:inline">
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Del</button>
</form>
</div>
</div>
<!-- Child items -->
{% for child in child_map.get(li.id|string, []) %}
<div class="list-row {{ 'completed' if child.completed }}" style="padding-left: 48px;">
{% if item.list_type == 'checklist' %}
<div class="row-check">
<form action="/lists/{{ item.id }}/items/{{ child.id }}/toggle" method="post" style="display:inline">
<input type="checkbox" id="li-{{ child.id }}" {{ 'checked' if child.completed }}
onchange="this.form.submit()">
<label for="li-{{ child.id }}"></label>
</form>
</div>
{% endif %}
<span class="row-title" style="{{ 'text-decoration: line-through; opacity: 0.6;' if child.completed }}">
{{ child.content }}
</span>
<div class="row-actions">
<form action="/lists/{{ item.id }}/items/{{ child.id }}/delete" method="post" style="display:inline">
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Del</button>
</form>
</div>
</div>
{% endfor %}
{% endfor %}
</div>
{% else %}
<div class="empty-state mt-3">
<div class="empty-state-icon">&#9744;</div>
<div class="empty-state-text">No items yet. Add one above.</div>
</div>
{% endif %}
<!-- Contacts -->
<div class="card mt-4">
<div class="card-header">
<h3 class="card-title">Contacts<span class="page-count">{{ contacts|length }}</span></h3>
</div>
<form action="/lists/{{ item.id }}/contacts/add" method="post" class="flex gap-2 items-end" style="padding: 12px; border-bottom: 1px solid var(--border);">
<div class="form-group" style="flex:1; margin:0;">
<select name="contact_id" class="form-select" required>
<option value="">Select contact...</option>
{% for c in all_contacts %}
<option value="{{ c.id }}">{{ c.first_name }} {{ c.last_name or '' }}</option>
{% endfor %}
</select>
</div>
<div class="form-group" style="flex:1; margin:0;">
<input type="text" name="role" class="form-input" placeholder="Role (optional)">
</div>
<button type="submit" class="btn btn-primary btn-sm">Add</button>
</form>
{% for c in contacts %}
<div class="list-row">
<span class="row-title"><a href="/contacts/{{ c.id }}">{{ c.first_name }} {{ c.last_name or '' }}</a></span>
{% if c.role %}<span class="row-tag">{{ c.role }}</span>{% endif %}
<div class="row-actions">
<form action="/lists/{{ item.id }}/contacts/{{ c.id }}/remove" method="post" style="display:inline">
<button class="btn btn-ghost btn-xs" title="Remove">Remove</button>
</form>
</div>
</div>
{% else %}
<div style="padding: 12px; color: var(--muted); font-size: 0.85rem;">No contacts linked</div>
{% endfor %}
</div>
{% endblock %}

84
templates/list_form.html Normal file
View File

@@ -0,0 +1,84 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">{{ page_title }}</h1>
</div>
<div class="card">
<form method="post" action="{{ '/lists/' ~ item.id ~ '/edit' if item else '/lists/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>
<option value="">Select domain...</option>
{% 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 }}>
{{ a.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">
<label class="form-label">Type</label>
<select name="list_type" class="form-select">
<option value="checklist" {{ 'selected' if item and item.list_type == 'checklist' }}>Checklist</option>
<option value="ordered" {{ 'selected' if item and item.list_type == 'ordered' }}>Ordered</option>
<option value="reference" {{ 'selected' if item and item.list_type == 'reference' }}>Reference</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-group">
<label class="form-label">Tags</label>
<input type="text" name="tags" class="form-input" placeholder="tag1, tag2, ..."
value="{{ item.tags|join(', ') if item and item.tags else '' }}">
</div>
</div>
{% if prefill_task_id is defined and prefill_task_id %}<input type="hidden" name="task_id" value="{{ prefill_task_id }}">{% endif %}
{% if prefill_meeting_id is defined and prefill_meeting_id %}<input type="hidden" name="meeting_id" value="{{ prefill_meeting_id }}">{% endif %}
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'Save Changes' if item else 'Create List' }}</button>
<a href="{{ '/tasks/' ~ prefill_task_id ~ '?tab=lists' if prefill_task_id is defined and prefill_task_id else ('/lists/' ~ item.id if item else '/lists') }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
{% endblock %}

94
templates/lists.html Normal file
View File

@@ -0,0 +1,94 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Lists<span class="page-count">{{ items|length }}</span></h1>
<a href="/lists/create" class="btn btn-primary">+ New List</a>
</div>
<!-- Filters -->
<form class="filters-bar" method="get" action="/lists" id="list-filters">
<select name="domain_id" class="filter-select" id="domain-filter" 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" id="project-filter" 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>
</form>
{% if items %}
<div class="card mt-3">
{% for item in items %}
<div class="list-row">
<span class="row-title"><a href="/lists/{{ item.id }}">{{ item.name }}</a></span>
<span class="row-meta">
{{ item.completed_count }}/{{ item.item_count }} items
</span>
{% if item.item_count > 0 %}
<div class="progress-bar" style="width: 80px;">
<div class="progress-fill" style="width: {{ (item.completed_count / item.item_count * 100) if item.item_count > 0 else 0 }}%"></div>
</div>
{% endif %}
<span class="row-tag">{{ item.list_type }}</span>
{% 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.project_name %}
<span class="row-tag">{{ item.project_name }}</span>
{% endif %}
<div class="row-actions">
<a href="/lists/{{ item.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
<form action="/lists/{{ item.id }}/delete" method="post" data-confirm="Delete this list?" 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 mt-3">
<div class="empty-state-icon">&#9776;</div>
<div class="empty-state-text">No lists yet</div>
<a href="/lists/create" class="btn btn-primary">Create First List</a>
</div>
{% endif %}
<script>
(function() {
var domainSel = document.getElementById('domain-filter');
var projectSel = document.getElementById('project-filter');
var currentProjectId = '{{ current_project_id }}';
domainSel.addEventListener('change', function() {
var did = domainSel.value;
if (!did) {
// No domain filter - submit immediately, server returns all projects
document.getElementById('list-filters').submit();
return;
}
// Fetch projects for this domain
fetch('/projects/api/by-domain?domain_id=' + encodeURIComponent(did))
.then(function(r) { return r.json(); })
.then(function(projects) {
projectSel.innerHTML = '<option value="">All Projects</option>';
projects.forEach(function(p) {
var opt = document.createElement('option');
opt.value = p.id;
opt.textContent = p.name;
if (p.id === currentProjectId) opt.selected = true;
projectSel.appendChild(opt);
});
document.getElementById('list-filters').submit();
})
.catch(function() {
document.getElementById('list-filters').submit();
});
});
})();
</script>
{% endblock %}

View File

@@ -0,0 +1,237 @@
{% extends "base.html" %}
{% block content %}
<div class="breadcrumb">
<a href="/meetings">Meetings</a>
<span class="sep">/</span>
<span>{{ item.title }}</span>
</div>
<div class="detail-header">
<h1 class="detail-title">{{ item.title }}</h1>
<div class="flex gap-2">
<a href="/meetings/{{ item.id }}/edit" class="btn btn-secondary btn-sm">Edit</a>
<form action="/meetings/{{ item.id }}/delete" method="post" data-confirm="Delete this meeting?" style="display:inline">
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form>
</div>
</div>
<div class="detail-meta mt-2">
<span class="detail-meta-item">{{ item.meeting_date }}</span>
<span class="status-badge status-{{ item.status }}">{{ item.status }}</span>
{% if item.location %}<span class="detail-meta-item">{{ item.location }}</span>{% endif %}
{% if item.start_at and item.end_at %}
<span class="detail-meta-item">{{ item.start_at.strftime('%H:%M') }} - {{ item.end_at.strftime('%H:%M') }}</span>
{% endif %}
{% if projects %}
{% for p in projects %}
<span class="detail-meta-item"><a href="/projects/{{ p.id }}" style="color: {{ p.domain_color or 'var(--accent)' }};">{{ p.name }}</a></span>
{% endfor %}
{% endif %}
{% if item.tags %}
<div class="mt-1">{% for tag in item.tags %}<span class="row-tag">{{ tag }}</span>{% endfor %}</div>
{% endif %}
</div>
<!-- Tabs -->
<div class="tab-strip">
<a href="/meetings/{{ item.id }}?tab=overview" class="tab-item {{ 'active' if tab == 'overview' }}">Overview</a>
<a href="/meetings/{{ item.id }}?tab=notes" class="tab-item {{ 'active' if tab == 'notes' }}">Notes{% if counts.notes %} ({{ counts.notes }}){% endif %}</a>
<a href="/meetings/{{ item.id }}?tab=links" class="tab-item {{ 'active' if tab == 'links' }}">Links{% if counts.links %} ({{ counts.links }}){% endif %}</a>
<a href="/meetings/{{ item.id }}?tab=files" class="tab-item {{ 'active' if tab == 'files' }}">Files{% if counts.files %} ({{ counts.files }}){% endif %}</a>
<a href="/meetings/{{ item.id }}?tab=lists" class="tab-item {{ 'active' if tab == 'lists' }}">Lists{% if counts.lists %} ({{ counts.lists }}){% endif %}</a>
<a href="/meetings/{{ item.id }}?tab=decisions" class="tab-item {{ 'active' if tab == 'decisions' }}">Decisions{% if counts.decisions %} ({{ counts.decisions }}){% endif %}</a>
<a href="/meetings/{{ item.id }}?tab=processes" class="tab-item {{ 'active' if tab == 'processes' }}">Processes</a>
<a href="/meetings/{{ item.id }}?tab=contacts" class="tab-item {{ 'active' if tab == 'contacts' }}">Contacts{% if counts.contacts %} ({{ counts.contacts }}){% endif %}</a>
</div>
{% if tab == 'overview' %}
<!-- Agenda -->
{% if item.agenda %}
<div class="card mb-4">
<div class="card-header"><h3 class="card-title">Agenda</h3></div>
<div class="detail-body" style="padding: 12px 16px;">{{ item.agenda }}</div>
</div>
{% endif %}
<!-- Meeting Notes -->
{% if item.notes_body %}
<div class="card mb-4">
<div class="card-header"><h3 class="card-title">Notes</h3></div>
<div class="detail-body" style="padding: 12px 16px;">{{ item.notes_body }}</div>
</div>
{% endif %}
<!-- Transcript -->
{% if item.transcript %}
<div class="card mb-4">
<div class="card-header"><h3 class="card-title">Transcript</h3></div>
<div class="detail-body" style="padding: 12px 16px; font-family: var(--font-mono); font-size: 0.82rem; white-space: pre-wrap;">{{ item.transcript }}</div>
</div>
{% endif %}
<!-- Action Items -->
<div class="card mb-4">
<div class="card-header">
<h3 class="card-title">Action Items<span class="page-count">{{ action_items|length }}</span></h3>
</div>
<form class="quick-add" action="/meetings/{{ item.id }}/action-item" method="post" style="border-bottom: 1px solid var(--border);">
<input type="text" name="title" placeholder="Add action item..." required>
<select name="domain_id" class="filter-select" required style="width: auto;">
{% for d in domains %}
<option value="{{ d.id }}">{{ d.name }}</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-primary btn-sm">Add</button>
</form>
{% for task in action_items %}
<div class="list-row {{ 'completed' if task.status == 'done' }}">
<div class="row-check">
<form action="/tasks/{{ task.id }}/toggle" method="post" style="display:inline">
<input type="checkbox" id="mt-{{ task.id }}" {{ 'checked' if task.status == 'done' }}
onchange="this.form.submit()">
<label for="mt-{{ task.id }}"></label>
</form>
</div>
<span class="priority-dot priority-{{ task.priority }}"></span>
<span class="row-title"><a href="/tasks/{{ task.id }}">{{ task.title }}</a></span>
{% if task.project_name %}<span class="row-tag">{{ task.project_name }}</span>{% endif %}
<span class="status-badge status-{{ task.status }}">{{ task.status|replace('_', ' ') }}</span>
</div>
{% else %}
<div style="padding: 16px; color: var(--muted); font-size: 0.85rem;">No action items yet</div>
{% endfor %}
</div>
<!-- Decisions -->
{% if decisions %}
<div class="card">
<div class="card-header"><h3 class="card-title">Decisions<span class="page-count">{{ decisions|length }}</span></h3></div>
{% for dec in decisions %}
<div class="list-row">
<span class="row-title"><a href="/decisions/{{ dec.id }}">{{ dec.title }}</a></span>
<span class="status-badge status-{{ dec.status }}">{{ dec.status }}</span>
<span class="row-tag">{{ dec.impact }}</span>
</div>
{% endfor %}
</div>
{% endif %}
{% elif tab == 'notes' %}
<a href="/notes/create?meeting_id={{ item.id }}" class="btn btn-ghost btn-sm mb-3">+ New Note</a>
{% for n in tab_data %}
<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 linked to this meeting</div></div>
{% endfor %}
{% elif tab == 'links' %}
<a href="/links/create?meeting_id={{ item.id }}" class="btn btn-ghost btn-sm mb-3">+ New Link</a>
{% for w in tab_data %}
<div class="list-row">
<span class="row-title"><a href="{{ w.url }}" target="_blank">{{ w.label }}</a></span>
<span class="row-meta">{{ w.url[:50] }}{% if w.url|length > 50 %}...{% endif %}</span>
</div>
{% else %}
<div class="empty-state"><div class="empty-state-text">No links linked to this meeting</div></div>
{% endfor %}
{% elif tab == 'files' %}
<a href="/files/upload?context_type=meeting&context_id={{ item.id }}" class="btn btn-ghost btn-sm mb-3">+ Upload File</a>
{% for f in tab_data %}
<div class="list-row">
<span class="row-title"><a href="/files/{{ f.id }}">{{ f.original_filename }}</a></span>
<span class="row-meta">{{ f.created_at.strftime('%Y-%m-%d') if f.created_at else '' }}</span>
</div>
{% else %}
<div class="empty-state"><div class="empty-state-text">No files attached to this meeting</div></div>
{% endfor %}
{% elif tab == 'lists' %}
<a href="/lists/create?meeting_id={{ item.id }}" class="btn btn-ghost btn-sm mb-3">+ New List</a>
{% for l in tab_data %}
<div class="list-row">
<span class="row-title"><a href="/lists/{{ l.id }}">{{ l.name }}</a></span>
<span class="row-meta">{{ l.item_count }} items</span>
</div>
{% else %}
<div class="empty-state"><div class="empty-state-text">No lists linked to this meeting</div></div>
{% endfor %}
{% elif tab == 'decisions' %}
<div class="card mb-4">
<form action="/meetings/{{ item.id }}/decisions/add" method="post" class="flex gap-2 items-end" style="padding: 12px;">
<div class="form-group" style="flex:1; margin:0;">
<label class="form-label">Decision</label>
<select name="decision_id" class="form-select" required>
<option value="">Select decision...</option>
{% for d in all_decisions %}
<option value="{{ d.id }}">{{ d.title }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-primary btn-sm">Link</button>
</form>
</div>
<a href="/decisions/create?meeting_id={{ item.id }}" class="btn btn-ghost btn-sm mb-3">+ New Decision</a>
{% for d in tab_data %}
<div class="list-row">
<span class="row-title"><a href="/decisions/{{ d.id }}">{{ d.title }}</a></span>
<span class="status-badge status-{{ d.status }}">{{ d.status }}</span>
{% if d.impact %}<span class="row-tag">{{ d.impact }}</span>{% endif %}
{% if d.decided_at %}<span class="row-meta">{{ d.decided_at.strftime('%Y-%m-%d') if d.decided_at else '' }}</span>{% endif %}
<div class="row-actions">
<form action="/meetings/{{ item.id }}/decisions/{{ d.id }}/remove" method="post" style="display:inline">
<button class="btn btn-ghost btn-xs" title="Unlink">Unlink</button>
</form>
</div>
</div>
{% else %}
<div class="empty-state"><div class="empty-state-text">No decisions linked to this meeting</div></div>
{% endfor %}
{% elif tab == 'processes' %}
<div class="empty-state"><div class="empty-state-text">Process management coming soon</div></div>
{% elif tab == 'contacts' %}
<div class="card mb-4">
<form action="/meetings/{{ item.id }}/contacts/add" method="post" class="flex gap-2 items-end" style="padding: 12px;">
<div class="form-group" style="flex:1; margin:0;">
<label class="form-label">Contact</label>
<select name="contact_id" class="form-select" required>
<option value="">Select contact...</option>
{% for c in all_contacts %}
<option value="{{ c.id }}">{{ c.first_name }} {{ c.last_name or '' }}</option>
{% endfor %}
</select>
</div>
<div class="form-group" style="flex:1; margin:0;">
<label class="form-label">Role</label>
<select name="role" class="form-select">
<option value="attendee">Attendee</option>
<option value="organizer">Organizer</option>
<option value="optional">Optional</option>
</select>
</div>
<button type="submit" class="btn btn-primary btn-sm">Add</button>
</form>
</div>
{% for c in tab_data %}
<div class="list-row">
<span class="row-title"><a href="/contacts/{{ c.id }}">{{ c.first_name }} {{ c.last_name or '' }}</a></span>
{% if c.role %}<span class="row-tag">{{ c.role }}</span>{% endif %}
<span class="row-meta">{{ c.linked_at.strftime('%Y-%m-%d') if c.linked_at else '' }}</span>
<div class="row-actions">
<form action="/meetings/{{ item.id }}/contacts/{{ c.id }}/remove" method="post" style="display:inline">
<button class="btn btn-ghost btn-xs" title="Remove">Remove</button>
</form>
</div>
</div>
{% else %}
<div class="empty-state"><div class="empty-state-text">No contacts linked to this meeting</div></div>
{% endfor %}
{% endif %}
{% endblock %}

102
templates/meeting_form.html Normal file
View File

@@ -0,0 +1,102 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">{{ page_title }}</h1>
</div>
<div class="card">
<form method="post" action="{{ '/meetings/' ~ item.id ~ '/edit' if item else '/meetings/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">Date *</label>
<input type="date" name="meeting_date" class="form-input" required
value="{{ item.meeting_date if item else '' }}">
</div>
<div class="form-group">
<label class="form-label">Status</label>
<select name="status" class="form-select">
<option value="scheduled" {{ 'selected' if item and item.status == 'scheduled' }}>Scheduled</option>
<option value="completed" {{ 'selected' if item and item.status == 'completed' }}>Completed</option>
<option value="cancelled" {{ 'selected' if item and item.status == 'cancelled' }}>Cancelled</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Start Time</label>
<input type="datetime-local" name="start_at" class="form-input"
value="{{ item.start_at.strftime('%Y-%m-%dT%H:%M') if item and item.start_at else '' }}">
</div>
<div class="form-group">
<label class="form-label">End Time</label>
<input type="datetime-local" name="end_at" class="form-input"
value="{{ item.end_at.strftime('%Y-%m-%dT%H:%M') if item and item.end_at else '' }}">
</div>
<div class="form-group">
<label class="form-label">Location</label>
<input type="text" name="location" class="form-input" placeholder="Zoom, Google Meet, Room..."
value="{{ item.location if item and item.location else '' }}">
</div>
<div class="form-group">
<label class="form-label">Priority</label>
<select name="priority" class="form-select">
<option value="">None</option>
<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 }}>Normal</option>
<option value="4" {{ 'selected' if item and item.priority == 4 }}>Low</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Series (Parent Meeting)</label>
<select name="parent_id" class="form-select">
<option value="">None</option>
{% for m in parent_meetings %}
<option value="{{ m.id }}" {{ 'selected' if item and item.parent_id and item.parent_id|string == m.id|string }}>
{{ m.title }} ({{ m.meeting_date }})
</option>
{% endfor %}
</select>
</div>
<div class="form-group full-width">
<label class="form-label">Agenda</label>
<textarea name="agenda" class="form-textarea" rows="4">{{ item.agenda if item and item.agenda else '' }}</textarea>
</div>
{% if item %}
<div class="form-group full-width">
<label class="form-label">Transcript</label>
<textarea name="transcript" class="form-textarea" rows="6">{{ item.transcript if item and item.transcript else '' }}</textarea>
</div>
<div class="form-group full-width">
<label class="form-label">Meeting Notes</label>
<textarea name="notes_body" class="form-textarea" rows="6">{{ item.notes_body if item and item.notes_body else '' }}</textarea>
</div>
{% endif %}
<div class="form-group">
<label class="form-label">Tags</label>
<input type="text" name="tags" class="form-input" placeholder="tag1, tag2, ..."
value="{{ item.tags|join(', ') if item and item.tags else '' }}">
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'Save Changes' if item else 'Create Meeting' }}</button>
<a href="{{ '/meetings/' ~ item.id if item else '/meetings' }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
{% endblock %}

46
templates/meetings.html Normal file
View File

@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Meetings<span class="page-count">{{ items|length }}</span></h1>
<a href="/meetings/create" class="btn btn-primary">+ New Meeting</a>
</div>
<form class="filters-bar" method="get" action="/meetings">
<select name="status" class="filter-select" onchange="this.form.submit()">
<option value="">All Statuses</option>
<option value="scheduled" {{ 'selected' if current_status == 'scheduled' }}>Scheduled</option>
<option value="completed" {{ 'selected' if current_status == 'completed' }}>Completed</option>
<option value="cancelled" {{ 'selected' if current_status == 'cancelled' }}>Cancelled</option>
</select>
</form>
{% if items %}
<div class="card mt-3">
{% for item in items %}
<div class="list-row">
<span class="row-title"><a href="/meetings/{{ item.id }}">{{ item.title }}</a></span>
<span class="row-meta">{{ item.meeting_date }}</span>
{% if item.location %}
<span class="row-tag">{{ item.location }}</span>
{% endif %}
{% if item.action_count %}
<span class="row-meta">{{ item.action_count }} action{{ 's' if item.action_count != 1 }}</span>
{% endif %}
<span class="status-badge status-{{ item.status }}">{{ item.status }}</span>
<div class="row-actions">
<a href="/meetings/{{ item.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
<form action="/meetings/{{ item.id }}/delete" method="post" data-confirm="Delete this meeting?" 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 mt-3">
<div class="empty-state-icon">&#128197;</div>
<div class="empty-state-text">No meetings yet</div>
<a href="/meetings/create" class="btn btn-primary">Schedule First Meeting</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 %}

21
templates/note_form.html Normal file
View File

@@ -0,0 +1,21 @@
{% 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">
{% if prefill_task_id is defined and prefill_task_id %}<input type="hidden" name="task_id" value="{{ prefill_task_id }}">{% endif %}
{% if prefill_meeting_id is defined and prefill_meeting_id %}<input type="hidden" name="meeting_id" value="{{ prefill_meeting_id }}">{% endif %}
<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,133 @@
{% extends "base.html" %}
{% block content %}
<div class="breadcrumb">
<a href="/processes">Processes</a>
<span class="sep">/</span>
<a href="/processes/{{ run.process_id_ref }}">{{ run.process_name }}</a>
<span class="sep">/</span>
<span>{{ run.title }}</span>
</div>
<div class="detail-header">
<h1 class="detail-title">{{ run.title }}</h1>
<div class="flex gap-2">
{% if run.status != 'completed' %}
<form action="/processes/runs/{{ run.id }}/complete" method="post" data-confirm="Mark this run as complete?" style="display:inline">
<button type="submit" class="btn btn-primary btn-sm">Mark Complete</button>
</form>
{% endif %}
<form action="/processes/runs/{{ run.id }}/delete" method="post" data-confirm="Delete this run?" style="display:inline">
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form>
</div>
</div>
<div class="detail-meta mt-2">
<span class="status-badge status-{{ run.status }}">{{ run.status|replace('_', ' ') }}</span>
<span class="row-tag">{{ run.process_type }}</span>
<span class="row-tag">{{ run.task_generation|replace('_', ' ') }}</span>
{% if run.project_name %}<span class="detail-meta-item">{{ run.project_name }}</span>{% endif %}
{% if run.contact_first %}<span class="detail-meta-item">{{ run.contact_first }} {{ run.contact_last or '' }}</span>{% endif %}
{% if run.started_at %}<span class="detail-meta-item">Started {{ run.started_at.strftime('%Y-%m-%d') }}</span>{% endif %}
{% if run.completed_at %}<span class="detail-meta-item">Completed {{ run.completed_at.strftime('%Y-%m-%d') }}</span>{% endif %}
</div>
<!-- Progress Bar -->
<div class="card mt-3" style="padding: 16px;">
<div style="display: flex; align-items: center; gap: 12px;">
<span style="font-weight: 600; font-size: 0.9rem;">Progress</span>
<div style="flex: 1; height: 8px; background: var(--border); border-radius: 4px; overflow: hidden;">
<div style="width: {{ (completed_steps / total_steps * 100)|int if total_steps > 0 else 0 }}%; height: 100%; background: var(--green); border-radius: 4px; transition: width 0.3s;"></div>
</div>
<span style="font-weight: 600; font-size: 0.9rem;">{{ completed_steps }}/{{ total_steps }}</span>
</div>
</div>
<!-- Steps Checklist -->
<div class="card mt-3">
<div class="card-header">
<h3 class="card-title">Steps</h3>
</div>
{% for step in steps %}
<div class="list-row {{ 'completed' if step.status == 'completed' }}" style="align-items: flex-start;">
<div class="row-check">
{% if step.status == 'completed' %}
<form action="/processes/runs/{{ run.id }}/steps/{{ step.id }}/uncomplete" method="post" style="display:inline">
<input type="checkbox" id="step-{{ step.id }}" checked onchange="this.form.submit()">
<label for="step-{{ step.id }}"></label>
</form>
{% else %}
<form action="/processes/runs/{{ run.id }}/steps/{{ step.id }}/complete" method="post" style="display:inline" id="complete-form-{{ step.id }}">
<input type="checkbox" id="step-{{ step.id }}" onchange="this.form.submit()">
<label for="step-{{ step.id }}"></label>
</form>
{% endif %}
</div>
<div style="flex: 1;">
<div style="display: flex; align-items: center; gap: 8px;">
<span class="row-meta" style="min-width: 20px; font-weight: 600;">{{ loop.index }}</span>
<span class="row-title" style="{{ 'text-decoration: line-through; opacity: 0.6;' if step.status == 'completed' }}">{{ step.title }}</span>
</div>
{% if step.instructions %}
<div style="color: var(--muted); font-size: 0.82rem; margin: 4px 0 0 28px;">{{ step.instructions }}</div>
{% endif %}
{% if step.completed_at %}
<div style="color: var(--green); font-size: 0.78rem; margin: 4px 0 0 28px;">
Completed {{ step.completed_at.strftime('%Y-%m-%d %H:%M') }}
</div>
{% endif %}
{% if step.notes %}
<div style="color: var(--muted); font-size: 0.82rem; margin: 2px 0 0 28px; font-style: italic;">{{ step.notes }}</div>
{% endif %}
{% if step.status != 'completed' %}
<div style="margin: 6px 0 0 28px;">
<button type="button" class="btn btn-ghost btn-xs" onclick="toggleNotes('{{ step.id }}')">Add Notes</button>
<div id="notes-{{ step.id }}" style="display: none; margin-top: 4px;">
<form action="/processes/runs/{{ run.id }}/steps/{{ step.id }}/complete" method="post" style="display: flex; gap: 6px; align-items: flex-end;">
<input type="text" name="notes" class="form-input" placeholder="Completion notes..." style="flex: 1; height: 32px; font-size: 0.82rem;">
<button type="submit" class="btn btn-primary btn-sm">Complete with Notes</button>
</form>
</div>
</div>
{% endif %}
<!-- Show linked tasks for this step -->
{% if step_tasks.get(step.id|string) %}
<div style="margin: 6px 0 0 28px;">
{% for task in step_tasks[step.id|string] %}
<div style="display: inline-flex; align-items: center; gap: 4px; margin-right: 8px;">
<span class="status-badge status-{{ task.status }}" style="font-size: 0.72rem;">{{ task.status }}</span>
<a href="/tasks/{{ task.id }}" style="font-size: 0.82rem;">{{ task.title }}</a>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
<!-- All Generated Tasks -->
{% if tasks %}
<div class="card mt-3">
<div class="card-header">
<h3 class="card-title">Generated Tasks<span class="page-count">{{ tasks|length }}</span></h3>
</div>
{% for task in tasks %}
<div class="list-row {{ 'completed' if task.status == 'done' }}">
<span class="priority-dot priority-{{ task.priority }}"></span>
<span class="row-title"><a href="/tasks/{{ task.id }}">{{ task.title }}</a></span>
{% if task.project_name %}<span class="row-tag">{{ task.project_name }}</span>{% endif %}
<span class="status-badge status-{{ task.status }}">{{ task.status|replace('_', ' ') }}</span>
</div>
{% endfor %}
</div>
{% endif %}
<script>
function toggleNotes(stepId) {
var el = document.getElementById('notes-' + stepId);
el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
</script>
{% endblock %}

View File

@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">All Process Runs<span class="page-count">{{ items|length }}</span></h1>
<a href="/processes" class="btn btn-secondary">Back to Processes</a>
</div>
<form class="filters-bar" method="get" action="/processes/runs">
<select name="status" class="filter-select" onchange="this.form.submit()">
<option value="">All Statuses</option>
<option value="not_started" {{ 'selected' if current_status == 'not_started' }}>Not Started</option>
<option value="in_progress" {{ 'selected' if current_status == 'in_progress' }}>In Progress</option>
<option value="completed" {{ 'selected' if current_status == 'completed' }}>Completed</option>
</select>
</form>
{% if items %}
<div class="card mt-3">
{% for item in items %}
<div class="list-row">
<span class="row-title"><a href="/processes/runs/{{ item.id }}">{{ item.title }}</a></span>
<span class="row-tag">{{ item.process_name }}</span>
<span class="status-badge status-{{ item.status }}">{{ item.status|replace('_', ' ') }}</span>
{% if item.total_steps > 0 %}
<div class="row-meta" style="display: flex; align-items: center; gap: 6px;">
<div style="width: 60px; height: 4px; background: var(--border); border-radius: 2px; overflow: hidden;">
<div style="width: {{ (item.completed_steps / item.total_steps * 100)|int }}%; height: 100%; background: var(--green); border-radius: 2px;"></div>
</div>
<span>{{ item.completed_steps }}/{{ item.total_steps }}</span>
</div>
{% endif %}
{% if item.project_name %}
<span class="row-tag">{{ item.project_name }}</span>
{% endif %}
<span class="row-meta">{{ item.created_at.strftime('%Y-%m-%d') }}</span>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state mt-3">
<div class="empty-state-icon">&#9654;</div>
<div class="empty-state-text">No process runs yet</div>
<a href="/processes" class="btn btn-primary">Go to Processes</a>
</div>
{% endif %}
{% endblock %}

52
templates/processes.html Normal file
View File

@@ -0,0 +1,52 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Processes<span class="page-count">{{ items|length }}</span></h1>
<div class="flex gap-2">
<a href="/processes/runs" class="btn btn-secondary">All Runs</a>
<a href="/processes/create" class="btn btn-primary">+ New Process</a>
</div>
</div>
<form class="filters-bar" method="get" action="/processes">
<select name="status" class="filter-select" onchange="this.form.submit()">
<option value="">All Statuses</option>
<option value="draft" {{ 'selected' if current_status == 'draft' }}>Draft</option>
<option value="active" {{ 'selected' if current_status == 'active' }}>Active</option>
<option value="archived" {{ 'selected' if current_status == 'archived' }}>Archived</option>
</select>
<select name="process_type" class="filter-select" onchange="this.form.submit()">
<option value="">All Types</option>
<option value="workflow" {{ 'selected' if current_type == 'workflow' }}>Workflow</option>
<option value="checklist" {{ 'selected' if current_type == 'checklist' }}>Checklist</option>
</select>
</form>
{% if items %}
<div class="card mt-3">
{% for item in items %}
<div class="list-row">
<span class="row-title"><a href="/processes/{{ item.id }}">{{ item.name }}</a></span>
<span class="row-tag">{{ item.process_type }}</span>
<span class="status-badge status-{{ item.status }}">{{ item.status }}</span>
<span class="row-meta">{{ item.step_count }} step{{ 's' if item.step_count != 1 }}</span>
{% if item.category %}
<span class="row-tag">{{ item.category }}</span>
{% endif %}
<div class="row-actions">
<a href="/processes/{{ item.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
<form action="/processes/{{ item.id }}/delete" method="post" data-confirm="Delete this process?" 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 mt-3">
<div class="empty-state-icon">&#9881;</div>
<div class="empty-state-text">No processes yet</div>
<a href="/processes/create" class="btn btn-primary">Create First Process</a>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,183 @@
{% extends "base.html" %}
{% block content %}
<div class="breadcrumb">
<a href="/processes">Processes</a>
<span class="sep">/</span>
<span>{{ item.name }}</span>
</div>
<div class="detail-header">
<h1 class="detail-title">{{ item.name }}</h1>
<div class="flex gap-2">
<a href="/processes/{{ item.id }}/edit" class="btn btn-secondary btn-sm">Edit</a>
<form action="/processes/{{ item.id }}/delete" method="post" data-confirm="Delete this process?" style="display:inline">
<button type="submit" 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 }}</span>
<span class="row-tag">{{ item.process_type }}</span>
{% if item.category %}<span class="detail-meta-item">{{ item.category }}</span>{% endif %}
<span class="detail-meta-item">{{ steps|length }} step{{ 's' if steps|length != 1 }}</span>
<span class="detail-meta-item">Created {{ item.created_at.strftime('%Y-%m-%d') }}</span>
{% if item.tags %}
<div class="mt-1">{% for tag in item.tags %}<span class="row-tag">{{ tag }}</span>{% endfor %}</div>
{% endif %}
</div>
{% if item.description %}
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Description</h3></div>
<div class="detail-body" style="padding: 12px 16px;">{{ item.description }}</div>
</div>
{% endif %}
<!-- Steps -->
<div class="card mt-3">
<div class="card-header">
<h3 class="card-title">Steps<span class="page-count">{{ steps|length }}</span></h3>
</div>
{% for step in steps %}
<div class="list-row" style="align-items: flex-start;">
<span class="row-meta" style="min-width: 28px; text-align: center; font-weight: 600;">{{ loop.index }}</span>
<div style="flex: 1;">
<span class="row-title">{{ step.title }}</span>
{% if step.instructions %}
<div style="color: var(--muted); font-size: 0.82rem; margin-top: 2px;">{{ step.instructions[:120] }}{{ '...' if step.instructions|length > 120 }}</div>
{% endif %}
{% if step.expected_output %}
<div style="color: var(--muted); font-size: 0.82rem; margin-top: 2px;">Output: {{ step.expected_output[:80] }}</div>
{% endif %}
</div>
{% if step.estimated_days %}
<span class="row-meta">{{ step.estimated_days }}d</span>
{% endif %}
<div class="row-actions">
<button type="button" class="btn btn-ghost btn-xs" onclick="toggleEditStep('{{ step.id }}')">Edit</button>
<form action="/processes/{{ item.id }}/steps/{{ step.id }}/delete" method="post" data-confirm="Delete this step?" style="display:inline">
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Del</button>
</form>
</div>
</div>
<!-- Inline edit form (hidden by default) -->
<div id="edit-step-{{ step.id }}" style="display: none; border-bottom: 1px solid var(--border); padding: 12px 16px; background: var(--surface2);">
<form action="/processes/{{ item.id }}/steps/{{ step.id }}/edit" method="post">
<div class="form-grid">
<div class="form-group full-width">
<label class="form-label">Title *</label>
<input type="text" name="title" class="form-input" value="{{ step.title }}" required>
</div>
<div class="form-group full-width">
<label class="form-label">Instructions</label>
<textarea name="instructions" class="form-textarea" rows="2">{{ step.instructions or '' }}</textarea>
</div>
<div class="form-group">
<label class="form-label">Expected Output</label>
<input type="text" name="expected_output" class="form-input" value="{{ step.expected_output or '' }}">
</div>
<div class="form-group">
<label class="form-label">Estimated Days</label>
<input type="number" name="estimated_days" class="form-input" min="0" value="{{ step.estimated_days or '' }}">
</div>
</div>
<div class="form-actions" style="margin-top: 8px;">
<button type="submit" class="btn btn-primary btn-sm">Save Step</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="toggleEditStep('{{ step.id }}')">Cancel</button>
</div>
</form>
</div>
{% endfor %}
<!-- Quick add step -->
<form class="quick-add" action="/processes/{{ item.id }}/steps/add" method="post" style="border-top: 1px solid var(--border);">
<input type="text" name="title" placeholder="Add a step..." required>
<button type="submit" class="btn btn-primary btn-sm">Add Step</button>
</form>
</div>
<!-- Runs -->
<div class="card mt-3">
<div class="card-header">
<h3 class="card-title">Runs<span class="page-count">{{ runs|length }}</span></h3>
</div>
{% for run in runs %}
<div class="list-row">
<span class="row-title"><a href="/processes/runs/{{ run.id }}">{{ run.title }}</a></span>
<span class="status-badge status-{{ run.status }}">{{ run.status|replace('_', ' ') }}</span>
{% if run.total_steps > 0 %}
<span class="row-meta">{{ run.completed_steps }}/{{ run.total_steps }} steps</span>
{% endif %}
{% if run.project_name %}
<span class="row-tag">{{ run.project_name }}</span>
{% endif %}
<span class="row-meta">{{ run.created_at.strftime('%Y-%m-%d') }}</span>
<div class="row-actions">
<form action="/processes/runs/{{ run.id }}/delete" method="post" data-confirm="Delete this run?" style="display:inline">
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Del</button>
</form>
</div>
</div>
{% endfor %}
{% if not runs %}
<div style="padding: 16px; color: var(--muted); font-size: 0.85rem;">No runs yet</div>
{% endif %}
<!-- Start Run Form -->
{% if steps %}
<div style="border-top: 1px solid var(--border); padding: 12px 16px;">
<button type="button" class="btn btn-primary btn-sm" onclick="document.getElementById('start-run-form').style.display = document.getElementById('start-run-form').style.display === 'none' ? 'block' : 'none'">+ Start Run</button>
<div id="start-run-form" style="display: none; margin-top: 12px;">
<form action="/processes/{{ item.id }}/runs/start" method="post">
<div class="form-grid">
<div class="form-group full-width">
<label class="form-label">Run Title *</label>
<input type="text" name="title" class="form-input" required
value="{{ item.name }} - Run">
</div>
<div class="form-group">
<label class="form-label">Task Generation</label>
<select name="task_generation" class="form-select">
<option value="all_at_once">All at Once</option>
<option value="step_by_step">Step by Step</option>
</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 }}">{{ p.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label class="form-label">Contact</label>
<select name="contact_id" class="form-select">
<option value="">None</option>
{% for c in contacts %}
<option value="{{ c.id }}">{{ c.first_name }} {{ c.last_name or '' }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="form-actions" style="margin-top: 8px;">
<button type="submit" class="btn btn-primary btn-sm">Start Run</button>
</div>
</form>
</div>
</div>
{% endif %}
</div>
<script>
function toggleEditStep(stepId) {
var el = document.getElementById('edit-step-' + stepId);
el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
</script>
{% endblock %}

View File

@@ -0,0 +1,57 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">{{ page_title }}</h1>
</div>
<div class="card">
<form method="post" action="{{ '/processes/' ~ item.id ~ '/edit' if item else '/processes/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 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-group">
<label class="form-label">Type</label>
<select name="process_type" class="form-select">
<option value="checklist" {{ 'selected' if item and item.process_type == 'checklist' }}>Checklist</option>
<option value="workflow" {{ 'selected' if item and item.process_type == 'workflow' }}>Workflow</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Status</label>
<select name="status" class="form-select">
<option value="draft" {{ 'selected' if item and item.status == 'draft' }}>Draft</option>
<option value="active" {{ 'selected' if item and item.status == 'active' }}>Active</option>
<option value="archived" {{ 'selected' if item and item.status == 'archived' }}>Archived</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Category</label>
<input type="text" name="category" class="form-input" placeholder="e.g. Onboarding, Publishing..."
value="{{ item.category if item and item.category else '' }}">
</div>
<div class="form-group">
<label class="form-label">Tags</label>
<input type="text" name="tags" class="form-input" placeholder="tag1, tag2, ..."
value="{{ item.tags|join(', ') if item and item.tags else '' }}">
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'Save Changes' if item else 'Create Process' }}</button>
<a href="{{ '/processes/' ~ item.id if item else '/processes' }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More