77 KiB
Life OS - Comprehensive System Design Document
Version: 1.0 Date: 2026-03-02 Purpose: Complete rebuild reference. Contains everything needed to reconstruct this system from ground zero.
Part 1: System Overview
1. Project Identity & Purpose
Life OS is a single-user personal productivity web application. It is a unified system for managing every dimension of personal productivity: tasks, projects, notes, contacts, meetings, decisions, time tracking, file management, and more.
Design Philosophy:
- 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 entity gets PostgreSQL full-text search via tsvector/tsquery.
- Context travels with navigation. Drill-down pre-fills context into create forms.
- Single source of truth. One system for everything.
Live URL: https://lifeos-dev.invixiom.com
Server: defiant-01, 46.225.166.142, Ubuntu 24.04
Repository: github.com/mdombaugh/lifeos-dev (main branch)
2. Architecture Overview
Life OS is a server-rendered monolith. There is no SPA, no frontend framework, and no build pipeline.
Key Architectural Decisions:
| Decision | Choice | Rationale |
|---|---|---|
| Rendering | Server-side (Jinja2) | Simplicity; no JS framework to maintain |
| Data mutations | HTML form POST with 303 redirect (PRG) | Standard web pattern; no XHR/fetch for data ops |
| JavaScript role | UI state only (sidebar, search, timer, theme) | Minimal client JS; zero fetch for data changes |
| ORM | None. Raw SQL via text() |
Full control; no migration tooling needed |
| CRUD abstraction | Generic BaseRepository class | One class handles all 48 tables |
| Deletion | Soft delete everywhere | is_deleted + deleted_at; admin trash for recovery |
| Search | PostgreSQL tsvector/tsquery | Built-in, no external search service |
| Authentication | None (single user) | Phase 3 roadmap item |
| CSRF | None (single user) | Not needed for single-user system |
Request Flow:
Browser → Nginx (SSL termination) → Docker container (uvicorn) → FastAPI router
→ BaseRepository / raw SQL → PostgreSQL → Jinja2 template → HTML response
Data Mutation Flow (PRG Pattern):
Browser submits HTML <form> via POST → Router processes Form() params
→ BaseRepository.create/update/soft_delete → 303 redirect → GET page
3. Technology Stack
Every dependency with exact version:
Python Dependencies (requirements.txt)
| Package | Version | Purpose |
|---|---|---|
| fastapi | 0.115.6 | Web framework (async) |
| uvicorn[standard] | 0.34.0 | ASGI server with hot reload |
| sqlalchemy[asyncio] | 2.0.36 | SQL toolkit (async engine, text() only - NO ORM models) |
| asyncpg | 0.30.0 | PostgreSQL async driver |
| jinja2 | 3.1.4 | Template engine |
| python-multipart | 0.0.18 | Form data parsing |
| python-dotenv | 1.0.1 | Environment variable loading |
| pydantic | 2.10.3 | Data validation (FastAPI dependency) |
| pyyaml | 6.0.2 | Configuration parsing |
| aiofiles | 24.1.0 | Async file I/O for uploads |
Infrastructure
| Component | Version | Purpose |
|---|---|---|
| Python | 3.12-slim (Docker) | Application runtime |
| PostgreSQL | 16-alpine (Docker) | Database (3 databases: dev, prod, test) |
| Nginx | Host-level (Ubuntu) | Reverse proxy, SSL termination |
| Docker | Host-level | Container runtime |
| Let's Encrypt | Auto-renew | SSL certificates |
Frontend (Zero Build)
| Component | Source | Purpose |
|---|---|---|
| Vanilla CSS | /static/style.css |
All styling, ~1200 lines |
| Vanilla JS | /static/app.js |
UI interactions only, ~190 lines |
| DM Sans | Google Fonts CDN | Body font |
| JetBrains Mono | Google Fonts CDN | Code/monospace font |
Hard Rules:
- No npm. No React. No Tailwind. No build tools.
- No fetch/XHR for data mutations. Forms use standard POST with 303 redirect.
- JavaScript handles ONLY: sidebar collapse, search modal, timer display, theme toggle, mobile nav, filter auto-submit, delete confirmation.
Part 2: Infrastructure
4. Server & Network Topology
Server: defiant-01 (46.225.166.142)
OS: Ubuntu 24.04
Network: lifeos_network (Docker bridge, 172.21.0.0/16)
Internet
│
▼
┌────────────────┐
│ Nginx (host) │ SSL termination
│ Port 80/443 │
└───────┬────────┘
│
┌────────────┴────────────┐
│ │
▼ ▼
┌─────────────────┐ ┌──────────────────┐
│ lifeos-dev │ │ lifeos-prod │
│ Port 8003 │ │ Port 8002 │
│ (hot reload) │ │ (not yet deployed)
└────────┬────────┘ └────────┬─────────┘
│ │
└───────────┬───────────┘
▼
┌─────────────────┐
│ lifeos-db │
│ PostgreSQL 16 │
│ Port 5432 │
│ (internal) │
└─────────────────┘
5. Docker Configuration
Dockerfile
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p /opt/lifeos/files
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
Container Run Commands
Dev Container (hot reload, volume mount):
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
The volume mount -v /opt/lifeos/dev:/app means any file change on the host is immediately visible inside the container. Combined with --reload, uvicorn detects changes and restarts automatically. No container rebuild needed.
Database Container:
docker run -d \
--name lifeos-db \
--network lifeos_network \
--restart unless-stopped \
-e POSTGRES_PASSWORD=UCTOQDZiUhN8U \
-v lifeos_pgdata:/var/lib/postgresql/data \
postgres:16-alpine
Environment Variables (.env)
DATABASE_URL=postgresql+asyncpg://postgres:UCTOQDZiUhN8U@lifeos-db:5432/lifeos_dev
FILE_STORAGE_PATH=/opt/lifeos/files/dev
ENVIRONMENT=development
6. Nginx & SSL
Config location: /etc/nginx/sites-available/invixiom
Domain routing:
lifeos-dev.invixiom.com→localhost:8003(dev)lifeos.invixiom.com→localhost:8002(prod, pending)
SSL certificate path: /etc/letsencrypt/live/kasm.invixiom.com-0001/
Note: The cert uses the kasm.invixiom.com-0001 path (not kasm.invixiom.com). This is a known quirk of the Let's Encrypt renewal structure on this server.
7. Database Configuration
Three databases in lifeos-db:
| Database | Purpose | Used By |
|---|---|---|
lifeos_dev |
Active development | lifeos-dev container |
lifeos_prod |
Production data (DO NOT TOUCH) | lifeos-prod container |
lifeos_test |
Test suite | pytest via tests/conftest.py |
Connection pooling (SQLAlchemy):
engine = create_async_engine(
DATABASE_URL,
echo=os.getenv("ENVIRONMENT") == "development",
pool_size=5,
max_overflow=10,
pool_pre_ping=True,
)
Session management:
async_session_factory = async_sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False,
)
async def get_db():
async with async_session_factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
Part 3: Database Schema
8. Schema Conventions
Every table follows these universal conventions:
Standard Column Set
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- [entity-specific columns]
tags TEXT[], -- Array of string tags
sort_order INT NOT NULL DEFAULT 0, -- Manual ordering
is_deleted BOOL NOT NULL DEFAULT false, -- Soft delete flag
deleted_at TIMESTAMPTZ, -- When soft-deleted
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- Auto-set on insert
updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -- Auto-set on update
Searchable Tables Add
search_vector TSVECTOR -- Full-text search column
-- Plus GIN index:
CREATE INDEX idx_tablename_search ON tablename USING GIN(search_vector);
-- Plus trigger:
CREATE TRIGGER trg_tablename_search
BEFORE INSERT OR UPDATE ON tablename
FOR EACH ROW EXECUTE FUNCTION update_tablename_search();
Soft Delete Pattern
is_deleted = falsefilter applied automatically by BaseRepositorydeleted_attimestamp records when deletion occurredsoft_delete()sets both fieldsrestore()clears both fieldspermanent_delete()does actual SQL DELETE (admin only)
Foreign Key Conventions
- Required parent references use
ON DELETE CASCADE - Optional references use
ON DELETE SET NULL - Junction tables use composite PKs with CASCADE on both sides
9. Core Entity Tables (31 tables)
domains
Life areas at the highest level (e.g., "Career", "Health", "Family").
CREATE TABLE domains (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
color TEXT, -- Hex color for UI badges
description TEXT,
icon TEXT,
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL 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 INDEX idx_domains_search ON domains USING GIN(search_vector);
CREATE INDEX idx_domains_sort ON domains USING btree(sort_order);
Search trigger: Indexes name.
areas
Subcategories within domains (e.g., "Software Engineering" under "Career").
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 INT NOT NULL DEFAULT 0,
is_deleted BOOL 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 INDEX idx_areas_search ON areas USING GIN(search_vector);
CREATE INDEX idx_areas_sort ON areas USING btree(domain_id, sort_order);
Search trigger: Indexes name, description.
projects
Concrete initiatives with status tracking and timelines.
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', -- active|on_hold|completed|archived
priority INT NOT NULL DEFAULT 3, -- 1=critical, 2=high, 3=normal, 4=low
start_date DATE,
target_date DATE,
completed_at TIMESTAMPTZ,
color TEXT,
tags TEXT[],
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL 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 INDEX idx_projects_search ON projects USING GIN(search_vector);
CREATE INDEX idx_projects_sort ON projects USING btree(domain_id, sort_order);
CREATE INDEX idx_projects_area_sort ON projects USING btree(area_id, sort_order);
CREATE INDEX idx_projects_status ON projects USING btree(status);
Search trigger: Indexes name, description, tags.
tasks
The core work unit. Supports subtasks, contexts, time estimates, and status workflow.
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, -- Subtask support
title TEXT NOT NULL,
description TEXT,
priority INT NOT NULL DEFAULT 3, -- 1=critical..4=low
status TEXT NOT NULL DEFAULT 'open', -- open|in_progress|blocked|done|cancelled
due_date DATE,
deadline TIMESTAMPTZ,
recurrence TEXT,
estimated_minutes INT,
energy_required TEXT, -- high|medium|low
context TEXT, -- @home, @office, @errands, etc.
is_custom_context BOOL 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 INT NOT NULL DEFAULT 0,
is_deleted BOOL 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()
);
CREATE INDEX idx_tasks_search ON tasks USING GIN(search_vector);
CREATE INDEX idx_tasks_status ON tasks USING btree(status);
CREATE INDEX idx_tasks_priority ON tasks USING btree(priority);
CREATE INDEX idx_tasks_due_date ON tasks USING btree(due_date);
CREATE INDEX idx_tasks_domain_sort ON tasks USING btree(domain_id, sort_order);
CREATE INDEX idx_tasks_project_sort ON tasks USING btree(project_id, sort_order);
CREATE INDEX idx_tasks_parent_sort ON tasks USING btree(parent_id, sort_order);
Search trigger: Indexes title, description, tags.
notes
Markdown-based notes linked to domains, projects, tasks, meetings, or folders.
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,
task_id UUID REFERENCES tasks(id) ON DELETE SET NULL,
title TEXT NOT NULL,
body TEXT,
content_format TEXT NOT NULL DEFAULT 'rich',
is_meeting_note BOOL NOT NULL DEFAULT false,
tags TEXT[],
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL 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 INDEX idx_notes_search ON notes USING GIN(search_vector);
CREATE INDEX idx_notes_task_id ON notes USING btree(task_id);
Search trigger: Indexes title, body, tags.
note_folders
Hierarchical folder system for organizing notes.
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 BOOL NOT NULL DEFAULT false,
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()
);
lists
Checklists and general-purpose lists linked to domains, projects, tasks, or meetings.
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,
task_id UUID REFERENCES tasks(id) ON DELETE SET NULL,
meeting_id UUID REFERENCES meetings(id) ON DELETE SET NULL,
name TEXT NOT NULL,
list_type TEXT NOT NULL DEFAULT 'checklist',
description TEXT,
tags TEXT[],
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL 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 INDEX idx_lists_search ON lists USING GIN(search_vector);
CREATE INDEX idx_lists_task_id ON lists USING btree(task_id);
CREATE INDEX idx_lists_meeting_id ON lists USING btree(meeting_id);
Search trigger: Indexes name, description, tags.
list_items
Individual items within a list. Supports nesting via parent_item_id.
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 BOOL NOT NULL DEFAULT false,
completed_at TIMESTAMPTZ,
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()
);
CREATE INDEX idx_list_items_sort ON list_items USING btree(list_id, sort_order);
CREATE INDEX idx_list_items_parent_sort ON list_items USING btree(parent_item_id, sort_order);
links
Quick-access bookmarks organized by domain/area/project.
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 INT NOT NULL DEFAULT 0,
is_deleted BOOL 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 INDEX idx_links_search ON links USING GIN(search_vector);
Search trigger: Indexes label, url, description, tags.
files
Uploaded file metadata. Actual files stored on disk at FILE_STORAGE_PATH.
CREATE TABLE files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
filename TEXT NOT NULL, -- Stored filename (UUID-based)
original_filename TEXT NOT NULL, -- User's original filename
storage_path TEXT NOT NULL, -- Full disk path
mime_type TEXT,
size_bytes INT,
description TEXT,
tags TEXT[],
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL 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 INDEX idx_files_search ON files USING GIN(search_vector);
Search trigger: Indexes original_filename, description, tags.
weblinks
Similar to links but with task/meeting association and folder organization.
CREATE TABLE weblinks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
label TEXT NOT NULL,
url TEXT NOT NULL,
description TEXT,
task_id UUID REFERENCES tasks(id) ON DELETE SET NULL,
meeting_id UUID REFERENCES meetings(id) ON DELETE SET NULL,
tags TEXT[],
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL 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 INDEX idx_weblinks_search ON weblinks USING GIN(search_vector);
CREATE INDEX idx_weblinks_task_id ON weblinks USING btree(task_id);
CREATE INDEX idx_weblinks_meeting_id ON weblinks USING btree(meeting_id);
Search trigger: Indexes label, url, description, tags.
weblink_folders
Hierarchical folder system for organizing weblinks.
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 BOOL NOT NULL DEFAULT false,
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()
);
CREATE INDEX idx_weblink_folders_sort ON weblink_folders USING btree(parent_id, sort_order);
contacts
People linked to tasks, projects, meetings, and decisions.
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 INT NOT NULL DEFAULT 0,
is_deleted BOOL 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 INDEX idx_contacts_search ON contacts USING GIN(search_vector);
Search trigger: Indexes first_name, last_name, company, email, tags.
appointments
Calendar events with start/end times and recurrence.
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 BOOL NOT NULL DEFAULT false,
recurrence TEXT,
tags TEXT[],
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL 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 INDEX idx_appointments_search ON appointments USING GIN(search_vector);
CREATE INDEX idx_appointments_start ON appointments USING btree(start_at);
Search trigger: Indexes title, description, location, tags.
meetings
Meetings with agenda, transcript, notes, and linked action items.
CREATE TABLE meetings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
parent_id UUID REFERENCES meetings(id) ON DELETE SET NULL, -- Recurring meeting series
title TEXT NOT NULL,
meeting_date DATE NOT NULL,
start_at TIMESTAMPTZ,
end_at TIMESTAMPTZ,
location TEXT,
status TEXT NOT NULL DEFAULT 'scheduled', -- scheduled|completed|cancelled
priority INT,
recurrence TEXT,
agenda TEXT,
transcript TEXT,
notes_body TEXT,
tags TEXT[],
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL 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 INDEX idx_meetings_search ON meetings USING GIN(search_vector);
CREATE INDEX idx_meetings_date ON meetings USING btree(meeting_date);
Search trigger: Indexes title, agenda, notes_body, tags.
decisions
Decision records with rationale, impact, status, and optional supersession chain.
CREATE TABLE decisions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
rationale TEXT,
status TEXT NOT NULL DEFAULT 'proposed', -- proposed|accepted|rejected|superseded
impact TEXT NOT NULL DEFAULT 'medium', -- low|medium|high
decided_at DATE,
task_id UUID REFERENCES tasks(id) ON DELETE SET NULL,
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 INT NOT NULL DEFAULT 0,
is_deleted BOOL 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 INDEX idx_decisions_search ON decisions USING GIN(search_vector);
CREATE INDEX idx_decisions_task_id ON decisions USING btree(task_id);
Search trigger: Indexes title, rationale, tags.
time_entries
Time tracking records. Note: No updated_at column - this is a known exception.
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, -- NULL = timer is running
duration_minutes INT,
notes TEXT,
is_deleted BOOL NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
-- NO updated_at column! BaseRepository.soft_delete() will fail. Use direct SQL.
);
CREATE INDEX idx_time_entries_task ON time_entries USING btree(task_id);
time_blocks
Scheduled time blocks for planning (distinct from time_entries which track actual work).
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 BOOL NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
time_budgets
Weekly hour allocations per domain with effective dating.
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 NUMERIC NOT NULL,
effective_from DATE NOT NULL,
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()
);
daily_focus
Links tasks to specific dates as "focus items" for that day.
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 INT,
completed BOOL NOT NULL DEFAULT false,
note 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()
);
CREATE INDEX idx_daily_focus_date ON daily_focus USING btree(focus_date);
processes
Template definitions for repeatable workflows/checklists.
CREATE TABLE processes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT,
process_type TEXT NOT NULL DEFAULT 'checklist', -- workflow|checklist
category TEXT,
status TEXT NOT NULL DEFAULT 'draft', -- draft|active|archived
tags TEXT[],
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL 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 INDEX idx_processes_search ON processes USING GIN(search_vector);
Search trigger: Indexes name, description, tags.
process_steps
Individual steps within a process template.
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 INT,
context 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()
);
process_runs
Instances of running a process. Copies steps as immutable snapshots.
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', -- not_started|in_progress|completed|cancelled
process_type TEXT NOT NULL,
task_generation TEXT NOT NULL DEFAULT 'all_at_once', -- all_at_once|step_by_step
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 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()
);
process_run_steps
Immutable step copies within a run, with completion tracking.
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', -- pending|in_progress|completed|skipped
completed_by_id UUID REFERENCES contacts(id) ON DELETE SET NULL,
completed_at TIMESTAMPTZ,
notes 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()
);
capture
Quick-capture inbox for unprocessed thoughts/items.
CREATE TABLE capture (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
raw_text TEXT NOT NULL,
processed BOOL NOT NULL DEFAULT false,
converted_to_type TEXT, -- task|note|project|contact|decision|weblink|list_item
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 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()
);
CREATE INDEX idx_capture_processed ON capture USING btree(processed);
context_types
System-defined context labels for tasks (e.g., @home, @office, @errands).
CREATE TABLE context_types (
id SERIAL PRIMARY KEY,
value TEXT NOT NULL UNIQUE,
label TEXT NOT NULL,
description TEXT,
is_system BOOL NOT NULL DEFAULT true,
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
reminders
Future-scheduled reminders linked to any entity type.
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 BOOL NOT NULL DEFAULT false,
is_deleted BOOL NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_reminders_entity ON reminders USING btree(entity_type, entity_id);
dependencies
Generic dependency relationships between any entity types. Supports cycle detection (Phase 2).
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 INT NOT NULL DEFAULT 0,
note TEXT,
is_deleted BOOL 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))
);
CREATE INDEX idx_dependencies_blocker ON dependencies USING btree(blocker_type, blocker_id);
CREATE INDEX idx_dependencies_dependent ON dependencies USING btree(dependent_type, dependent_id);
releases
Versioned release milestones spanning multiple projects.
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', -- planned|in_progress|released|cancelled
target_date DATE,
released_at DATE,
release_notes TEXT,
tags TEXT[],
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Search trigger: Indexes name, description, version_label, tags.
milestones
Date-based checkpoints within releases or projects.
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 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()
);
task_templates
Reusable task templates with preset fields.
CREATE TABLE task_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT,
priority INT,
estimated_minutes INT,
energy_required TEXT,
context TEXT,
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()
);
task_template_items
Individual sub-items within a task template.
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 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()
);
10. Junction Tables (16 tables)
All junction tables use composite primary keys and CASCADE deletes on both sides.
-- Note <-> Project (many-to-many)
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 BOOL NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (note_id, project_id)
);
-- Note <-> Note (wiki-style links)
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)
);
-- File <-> Any Entity (polymorphic mapping)
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, -- 'task', 'project', 'note', etc.
context_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (file_id, context_type, context_id)
);
CREATE INDEX idx_file_mappings_context ON file_mappings USING btree(context_type, context_id);
-- Release <-> Project
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)
);
-- Release <-> Domain
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)
);
-- Contact <-> Task
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)
);
-- Contact <-> Project
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)
);
-- Contact <-> List
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)
);
-- Contact <-> List Item
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,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (contact_id, list_item_id)
);
-- Contact <-> Appointment
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)
);
-- Contact <-> Meeting
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)
);
-- Decision <-> Project
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)
);
-- Decision <-> Contact
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)
);
-- Meeting <-> Task (action items)
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 Step <-> Task
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)
);
-- Folder <-> Weblink
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 INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (folder_id, weblink_id)
);
11. Database Functions & Triggers
Search Vector Update Functions
15 entity-specific search functions, each following the same pattern. The function builds a tsvector from relevant text columns and stores it in search_vector.
Pattern:
CREATE OR REPLACE FUNCTION update_{table}_search() RETURNS TRIGGER AS $$
BEGIN
NEW.search_vector := to_tsvector('pg_catalog.english',
coalesce(NEW.{col1}, '') || ' ' || coalesce(NEW.{col2}, '') || ' ' ||
coalesce(array_to_string(NEW.tags, ' '), ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_{table}_search
BEFORE INSERT OR UPDATE ON {table}
FOR EACH ROW EXECUTE FUNCTION update_{table}_search();
Per-table indexed columns:
| Function | Table | Indexed Columns |
|---|---|---|
update_domains_search |
domains | name |
update_areas_search |
areas | name, description |
update_projects_search |
projects | name, description, tags |
update_tasks_search |
tasks | title, description, tags |
update_notes_search |
notes | title, body, tags |
update_contacts_search |
contacts | first_name, last_name, company, email, tags |
update_meetings_search |
meetings | title, agenda, notes_body, tags |
update_decisions_search |
decisions | title, rationale, tags |
update_links_search |
links | label, url, description, tags |
update_weblinks_search |
weblinks | label, url, description, tags |
update_files_search |
files | original_filename, description, tags |
update_lists_search |
lists | name, description, tags |
update_appointments_search |
appointments | title, description, location, tags |
update_processes_search |
processes | name, description, tags |
update_releases_search |
releases | name, description, version_label, tags |
There is also a generic update_search_vector() function as a fallback that attempts to index title, description, name, and tags with exception handling for missing columns.
12. Entity Relationship Map
domains (1)─────────────┬──────(M) areas
│ │ │
│ │ └──(M) projects ◄──── areas.area_id
│ │ │
├──(M) projects │ ├──(M) tasks
├──(M) tasks │ ├──(M) notes
├──(M) notes │ ├──(M) links
├──(M) links │ ├──(M) lists
├──(M) lists │ └──(M) milestones
└──(M) time_budgets │
│
tasks ──────────────────┬──(M) time_entries
│ ├──(M) daily_focus
│ ├──(M) notes (via task_id)
│ ├──(M) lists (via task_id)
│ ├──(M) weblinks (via task_id)
│ ├──(M) decisions (via task_id)
│ ├──(M) subtasks (self-ref via parent_id)
│ └──(M) time_blocks
│
contacts ───────────────┬──(M) contact_tasks ──── tasks
│ ├──(M) contact_projects ── projects
│ ├──(M) contact_meetings ── meetings
│ ├──(M) contact_appointments ── appointments
│ ├──(M) contact_lists ──── lists
│ └──(M) decision_contacts ── decisions
│
meetings ───────────────┬──(M) meeting_tasks ──── tasks
│ ├──(M) contact_meetings ── contacts
│ ├──(M) notes (via meeting_id)
│ ├──(M) lists (via meeting_id)
│ ├──(M) weblinks (via meeting_id)
│ └──(M) decisions (via meeting_id)
│
processes ──────────────┬──(M) process_steps
│ └──(M) process_runs
│ ├──(M) process_run_steps
│ └──(M) process_run_tasks ── tasks
│
files ──────────────────── file_mappings ──── (polymorphic: any entity)
│
releases ───────────────┬──(M) release_projects ── projects
│ ├──(M) release_domains ── domains
│ ├──(M) tasks (via release_id)
│ └──(M) milestones
Part 4: Backend Application
13. Application Bootstrap (main.py)
Initialization Flow
# 1. Load environment variables
load_dotenv()
# 2. Create FastAPI app with lifespan
app = FastAPI(title="Life OS", version="1.0.0", lifespan=lifespan)
# 3. Mount static files
app.mount("/static", StaticFiles(directory="static"), name="static")
# 4. Add ASGI middleware for environment context
app.add_middleware(RequestContextMiddleware)
# 5. Define dashboard route (/)
# 6. Define health check (/health)
# 7. Include all 23 routers
Lifespan Handler
@asynccontextmanager
async def lifespan(app: FastAPI):
ok = await check_db() # Verify DB connectivity at startup
print("Database connection OK" if ok else "WARNING: Database check failed")
yield
RequestContextMiddleware
Pure ASGI middleware (not BaseHTTPMiddleware, which causes TaskGroup issues with asyncpg):
class RequestContextMiddleware:
async def __call__(self, scope, receive, send):
if scope["type"] == "http":
scope.setdefault("state", {})["environment"] = self.environment
await self.app(scope, receive, send)
Dashboard Route (/)
The dashboard queries:
- Today's focus items (daily_focus JOIN tasks, projects, domains)
- Overdue tasks (due_date < today, not done/cancelled, limit 10)
- Upcoming tasks (due in next 7 days, limit 10)
- Task stats (open count, done this week, in progress)
Router Registration Order
23 routers included in this order:
- domains, areas, projects, tasks, notes, links
- focus, capture, contacts, search, admin, lists
- files, meetings, decisions, weblinks
- appointments, time_tracking, processes, calendar
- time_budgets, eisenhower, history
14. Core Modules
database.py - Connection Management
Engine Configuration:
engine = create_async_engine(
DATABASE_URL,
echo=(ENVIRONMENT == "development"), # SQL logging in dev
pool_size=5,
max_overflow=10,
pool_pre_ping=True, # Verify connections before use
)
Session Factory:
async_session_factory = async_sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False,
)
get_db() Dependency: FastAPI dependency that yields an async session with auto-commit/rollback:
async def get_db():
async with async_session_factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
base_repository.py - Generic CRUD
The BaseRepository class provides all CRUD operations for any table. It uses raw SQL via text() - no ORM models.
Constructor: BaseRepository(table_name: str, db: AsyncSession)
Methods:
| Method | Signature | Behavior |
|---|---|---|
list() |
(filters, sort, sort_dir, page, per_page, include_deleted) |
SELECT with auto is_deleted=false filter. Supports pagination, sorting, and key-value filters. Special filter values: None → IS NULL, "__notnull__" → IS NOT NULL. |
count() |
(filters, include_deleted) |
COUNT with same filter logic |
get() |
(id: UUID) |
SELECT by ID. Validates UUID format. Returns dict or None. |
create() |
(data: dict) |
INSERT with auto created_at, updated_at, is_deleted=false. Coerces ISO date strings to Python date/datetime (required by asyncpg). Returns full row. |
update() |
(id: UUID, data: dict) |
UPDATE with auto updated_at. Nullable fields gotcha: Only fields listed in nullable_fields set can be set to NULL. All other NULL values are silently skipped. |
soft_delete() |
(id: UUID) |
Sets is_deleted=true, deleted_at=now(), updated_at=now() |
restore() |
(id: UUID) |
Sets is_deleted=false, deleted_at=NULL, updated_at=now() |
permanent_delete() |
(id: UUID) |
Actual SQL DELETE. Admin only. |
bulk_soft_delete() |
(ids: list[str]) |
Batch soft delete |
list_deleted() |
() |
Lists all soft-deleted rows, ordered by deleted_at DESC |
reorder() |
(id_order: list[str]) |
Updates sort_order based on position (10, 20, 30...) |
Nullable Fields Set:
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",
}
Date Coercion:
asyncpg requires native Python date/datetime objects, not strings. The _coerce_value() helper auto-converts ISO format strings (YYYY-MM-DD, YYYY-MM-DDTHH:MM) to Python types.
sidebar.py - Navigation Data
Builds the domain > area > project tree for the sidebar. Called by every route.
async def get_sidebar_data(db: AsyncSession) -> dict:
# Returns:
{
"domain_tree": [
{
"id": UUID, "name": str, "color": str,
"areas": [
{"id": UUID, "name": str, "projects": [...]}
],
"standalone_projects": [...] # Projects with no area
}
],
"capture_count": int, # Unprocessed capture items
"focus_count": int, # Incomplete focus items for today
}
15. Router Pattern
Every router follows this exact structure:
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')
# LIST - GET /things/
@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,
'page_title': 'Things', 'active_nav': 'things',
})
# CREATE FORM - GET /things/create
@router.get('/create')
async def create_form(request: Request, db=Depends(get_db)):
sidebar = await get_sidebar_data(db)
return templates.TemplateResponse('thing_form.html', {
'request': request, 'item': None, 'sidebar': sidebar,
})
# CREATE - POST /things/create
@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)
# DETAIL - GET /things/{id}
@router.get('/{thing_id}')
async def detail(request: Request, thing_id: str, db=Depends(get_db)):
repo = BaseRepository("things", db)
item = await repo.get(thing_id)
if not item:
return RedirectResponse(url='/things', status_code=303)
sidebar = await get_sidebar_data(db)
return templates.TemplateResponse('thing_detail.html', {
'request': request, 'item': item, 'sidebar': sidebar,
})
# EDIT FORM - GET /things/{id}/edit
@router.get('/{thing_id}/edit')
async def edit_form(request: Request, thing_id: str, db=Depends(get_db)):
repo = BaseRepository("things", db)
item = await repo.get(thing_id)
sidebar = await get_sidebar_data(db)
return templates.TemplateResponse('thing_form.html', {
'request': request, 'item': item, 'sidebar': sidebar,
})
# EDIT - POST /things/{id}/edit
@router.post('/{thing_id}/edit')
async def edit_thing(request: Request, thing_id: str, db=Depends(get_db),
title: str = Form(...), description: str = Form(None)):
repo = BaseRepository("things", db)
await repo.update(thing_id, {'title': title, 'description': description})
return RedirectResponse(url=f'/things/{thing_id}', status_code=303)
# DELETE - POST /things/{id}/delete
@router.post('/{thing_id}/delete')
async def delete_thing(request: Request, thing_id: str, db=Depends(get_db)):
repo = BaseRepository("things", db)
await repo.soft_delete(thing_id)
return RedirectResponse(url='/things', status_code=303)
Every route MUST call get_sidebar_data(db) and pass sidebar to the template.
16. Router Reference (23 routers)
Standard CRUD Routers
These follow the exact pattern above (list, create form, create, detail, edit form, edit, delete):
| Router | Prefix | Table | Extra Endpoints |
|---|---|---|---|
| domains | /domains | domains | - |
| areas | /areas | areas | - |
| projects | /projects | projects | GET /api/by-domain (JSON, for dynamic dropdowns) |
| notes | /notes | notes | - |
| links | /links | links | - |
| contacts | /contacts | contacts | - |
| lists | /lists | lists | POST /{id}/items/add, POST /items/{id}/toggle, POST /items/{id}/delete |
| meetings | /meetings | meetings | POST /{id}/create-action (create task from meeting) |
| decisions | /decisions | decisions | - |
| weblinks | /weblinks | weblinks | POST /folders/create, folder management |
| appointments | /appointments | appointments | - |
| processes | /processes | processes | POST /{id}/steps/add, POST /steps/{id}/edit, POST /{id}/start-run |
| time_budgets | /time-budgets | time_budgets | - |
Specialized Routers
tasks (/tasks) - Most complex router:
- Standard CRUD (list, create, detail, edit, delete)
POST /quick-add- Minimal task creation (title + domain_id only)POST /{id}/toggle- Toggle done/open statusPOST /{id}/complete- Mark as done with completed_atGET /with filters: status, priority, domain_id, project_id, sort, context- Detail page with tabs: overview, notes, weblinks, files, lists, decisions, contacts
POST /{id}/contacts/add- Link contact to taskPOST /{id}/contacts/{contact_id}/remove- Unlink contact
focus (/focus) - Daily focus management:
GET /- Show today's focus + available tasksPOST /add- Add task to focus (task_id + focus_date)POST /{id}/toggle- Toggle focus item completePOST /{id}/remove- Remove from focus
capture (/capture) - Quick capture inbox:
GET /- List unprocessed capturesPOST /add- Create capture (raw_text, supports multi-line splitting)GET /{id}/convert- Show conversion optionsPOST /{id}/convert/{type}- Convert to task/note/project/contact/decision/weblink/list_itemPOST /{id}/dismiss- Mark as processed without convertingPOST /batch/{batch_id}/undo- Undo batch conversion
search (/search) - Global search:
GET /- Search page with HTML resultsGET /api- JSON search API for modal (returns{results: [...]})- Searches across configurable
SEARCH_ENTITIESdict - Uses tsvector/tsquery with
plainto_tsquery()for safety
admin (/admin) - Trash management:
GET /trash- List soft-deleted items across all entitiesPOST /trash/{table}/{id}/restore- Restore soft-deleted itemPOST /trash/{table}/{id}/permanent-delete- Hard deletePOST /trash/empty- Empty all trash
time_tracking (/time) - Timer and time log:
GET /- Time log page with day filterPOST /start- Start timer for task (auto-stops any running timer)POST /stop- Stop running timerPOST /manual- Create manual time entryGET /running- JSON endpoint returning running timer statePOST /{id}/delete- Delete time entry (uses direct SQL, no BaseRepository)
calendar (/calendar) - Read-only unified calendar:
GET /- Month view combining appointments, meetings, and tasks with due dates- Query params:
year,month
eisenhower (/eisenhower) - Eisenhower matrix view:
GET /- 2x2 grid classifying tasks by priority (1-2=important) and urgency (due <=7 days)- Query params:
domain_id,project_id,status,context
history (/history) - Change history feed:
GET /- Reverse-chronological feed of recently modified items across all entities- Query params:
entity_typefilter
17. Business Logic
Timer Constraints
- Only one timer can run at a time. Starting a new timer auto-stops any running timer.
- Running timer detected by:
SELECT * FROM time_entries WHERE end_at IS NULL AND is_deleted = false - Stop sets
end_at = now()and computesduration_minutes - The topbar timer pill polls
/time/runningevery 30 seconds
Capture Conversions
- Multi-line
raw_textis split on newlines, creating multiple capture items - Each line in a batch gets the same
import_batch_id - Single-line captures get no batch_id
- Conversion creates the target entity and marks capture as
processed=true - Batch undo reverses all conversions for a batch_id
Task Status Transitions
- Toggle:
open↔done(sets/clearscompleted_at) - Complete action: Sets
status=done,completed_at=now() - Edit can change to any valid status
- Setting status to
doneauto-setscompleted_at - Setting status from
doneto anything else clearscompleted_at
Focus Sync
- Adding a task to daily_focus doesn't change its status
- Toggling a focus item as complete marks
completed=trueon the daily_focus row - Focus items for today are shown on the dashboard
Search Algorithm
- Uses PostgreSQL
plainto_tsquery('english', query)for safe query parsing - Searches
search_vector @@ querywith tsvector ranking - Results grouped by entity type with configurable display columns
- No special characters interpreted (safe from injection)
18. Key Enums & Constants
| Field | Valid Values | Default |
|---|---|---|
| Task status | open, in_progress, blocked, done, cancelled |
open |
| Task priority | 1 (critical), 2 (high), 3 (normal), 4 (low) |
3 |
| Project status | active, on_hold, completed, archived |
active |
| Meeting status | scheduled, completed, cancelled |
scheduled |
| Decision status | proposed, accepted, rejected, superseded |
proposed |
| Decision impact | low, medium, high |
medium |
| Process type | workflow, checklist |
checklist |
| Process status | draft, active, archived |
draft |
| Process run status | not_started, in_progress, completed, cancelled |
not_started |
| Run step status | pending, in_progress, completed, skipped |
pending |
| Task generation | all_at_once, step_by_step |
all_at_once |
| Release status | planned, in_progress, released, cancelled |
planned |
| Area status | active (default) |
active |
| Content format | rich (default) |
rich |
Part 5: Frontend
19. Template Architecture
All templates live in /opt/lifeos/dev/templates/ and extend base.html.
base.html Shell Structure
<html data-theme="dark">
<head>
<link rel="stylesheet" href="/static/style.css">
<link href="fonts.googleapis.com/css2?DM+Sans+JetBrains+Mono">
</head>
<body>
<div class="app-layout">
<aside class="sidebar">
<div class="sidebar-header">LifeOS logo</div>
<nav class="sidebar-nav">
[14 quick-access links with SVG icons]
[Domain tree: domain > area > project hierarchy]
[Admin section: History, Trash, Manage, Theme toggle]
</nav>
</aside>
<main class="main-content">
<header class="topbar">
[DEV badge if development]
[Timer pill (hidden by default)]
[Quick capture form]
[Search trigger button]
</header>
<div class="page-content">
{% block content %}{% endblock %}
</div>
</main>
</div>
[Search modal overlay]
[Mobile bottom nav bar]
[Mobile more panel overlay]
<script src="/static/app.js"></script>
</body>
</html>
Template Inventory (51 files)
Core: base.html, dashboard.html List views (18): tasks, notes, projects, contacts, areas, domains, links, lists, meetings, decisions, weblinks, appointments, time_entries, files, processes, process_runs, capture, time_budgets Detail views (9): task_detail, project_detail, note_detail, contact_detail, meeting_detail, appointment_detail, decision_detail, process_run_detail, list_detail Forms (15): task_form, note_form, project_form, contact_form, area_form, domain_form, link_form, list_form, meeting_form, decision_form, weblink_form, weblink_folder_form, appointment_form, processes_form, time_budgets_form Specialized (7): focus, calendar, eisenhower, search, trash, history, capture_convert File management (2): file_preview, file_upload
20. CSS Design System
Design Tokens (Custom Properties)
Dark Theme ([data-theme="dark"]):
--bg: #0D0E13; --surface: #14161F; --surface2: #1A1D28;
--surface3: #222533; --border: #252836; --border-light: #2E3244;
--text: #DDE1F5; --text-secondary: #9CA3C4; --muted: #5A6080;
--accent: #4F6EF7; --accent-hover: #6380FF; --accent-soft: rgba(79,110,247,.12);
--green: #22C98A; --green-soft: rgba(34,201,138,.12);
--amber: #F5A623; --amber-soft: rgba(245,166,35,.12);
--red: #F05252; --red-soft: rgba(240,82,82,.12);
--purple: #9B7FF5; --purple-soft: rgba(155,127,245,.12);
--shadow: 0 2px 8px rgba(0,0,0,.3);
--shadow-lg: 0 8px 32px rgba(0,0,0,.4);
Light Theme ([data-theme="light"]):
--bg: #F0F2F8; --surface: #FFFFFF; --surface2: #F7F8FC;
--text: #171926; --muted: #8892B0;
/* Same accent/semantic colors with adjusted soft variants */
Root Variables (theme-independent):
--font-body: 'DM Sans', -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--radius: 8px; --radius-sm: 4px; --radius-lg: 12px;
--sidebar-width: 260px; --topbar-height: 52px;
--transition: 150ms ease;
Color Usage by Context
| Context | Color |
|---|---|
| Priority 1 (Critical) | --red |
| Priority 2 (High) | --amber |
| Priority 3 (Normal) | --accent |
| Priority 4 (Low) | --muted |
| Status: open | --accent |
| Status: in_progress | --amber |
| Status: blocked | --red |
| Status: done/completed | --green |
| Status: cancelled/archived | --muted |
| Status: on_hold | --purple |
| Calendar: appointments | --amber |
| Calendar: meetings | --purple |
| Calendar: tasks | --accent |
Responsive Breakpoint
Single breakpoint: max-width: 768px
Mobile overrides:
- Sidebar:
display: none - Main content:
margin-left: 0 - Form grid: 1 column
- Dashboard grid: 1 column
- Page content:
padding: 16px - Mobile bottom nav:
display: flex(56px fixed bottom) - Body:
padding-bottom: 60px
21. JavaScript Interactions (app.js, ~190 lines)
All JS runs on DOMContentLoaded. No framework. No build step.
Theme Toggle
- Reads/saves theme to
localStorage['lifeos-theme'] - Sets
data-themeattribute on<html> - Toggle function called from sidebar button
Sidebar Domain Collapse
- Click
.domain-header→ toggle.collapsedon.domain-children - State persisted per domain in
localStorage['sidebar-{domainId}'] - Restored on page load
Filter Auto-Submit
- Any
<select data-auto-submit>→ onchange, submits parent<form>
Delete Confirmation
- Any
<form data-confirm="message">→ onsubmit, showsconfirm()dialog
Search Modal
- Open: Cmd/Ctrl+K or click search trigger
- Close: Escape key or click backdrop
- Live search: 200ms debounce, fetches
/search/api?q={query}&limit=8 - Navigation: Arrow keys move
.activeclass through results, Enter navigates - Results: Grouped by entity type, shows name + context + status badge
- HTML escaping:
escHtml()helper for safe rendering
Timer Pill
- Poll:
/time/runningevery 30 seconds - Display: If running, shows task name + elapsed time (h:mm:ss)
- Update: 1-second interval updates elapsed display client-side
- State:
timerStartAt(Date),timerInterval(setInterval ID)
Mobile More Panel
#mobMoreBtnclick → toggle.openon#mobMore+#mobOverlay- Overlay click → close panel
22. Page Patterns
List View Pattern
Page Header (title + count + "New" button)
└── Quick Add form (optional, for tasks)
└── Filters bar (select dropdowns with auto-submit)
└── Card
└── List rows (checkbox + priority dot + title + tags + meta + status + actions)
└── Empty state (if no items)
Form View Pattern
Breadcrumb (entity list > "New"/"Edit")
└── Page Header
└── Card
└── Form (grid: 2 columns on desktop, 1 on mobile)
└── Form groups (label + input/select/textarea)
└── Full-width fields (description, tags)
└── Form actions (Submit + Cancel)
Detail View Pattern
Breadcrumb (domain > project > item)
└── Detail Header (title + action buttons)
└── Detail Meta (status + priority + domain + project + dates)
└── Description card (if exists)
└── Tags row (if exists)
└── Tab Strip (overview, notes, weblinks, files, lists, etc.)
└── Tab Content (varies by tab)
└── Footer metadata (created/completed timestamps)
23. Responsive & Mobile
Desktop Layout
- Fixed sidebar (260px) + flexible main content
- Sticky topbar (52px)
- Two-column form grids
- Hover states reveal action buttons on list rows
Mobile Layout (< 768px)
- Sidebar hidden
- Bottom nav bar (56px) with 5 items: Home, Focus, Tasks, Capture, More
- "More" button opens slide-up panel with remaining navigation
- Forms collapse to single column
- Quick capture input expands to full width
- Font-size: 16px on inputs to prevent iOS zoom
- Touch targets: min 44px height for buttons
Part 6: Testing
24. Test Architecture
The test suite uses dynamic introspection - it discovers routes from the live FastAPI app at runtime. No hardcoded routes.
Test Files
| File | Lines | Purpose |
|---|---|---|
conftest.py |
361 | Fixtures, seed data, client setup |
registry.py |
67 | Route discovery, classification, seed mapping |
introspect.py |
357 | Route introspection engine |
form_factory.py |
204 | Automatic form data generation |
test_smoke_dynamic.py |
76 | Auto-parametrized GET tests |
test_crud_dynamic.py |
168 | Auto-parametrized POST tests |
test_business_logic.py |
2,635 | Hand-written behavioral tests |
route_report.py |
65 | CLI route dump utility |
run_tests.sh |
23 | Test runner script |
Introspection Engine (introspect.py)
Walks the live FastAPI app and extracts metadata for every route:
- Path, methods, endpoint name
- Route kind classification (LIST, DETAIL, CREATE, EDIT, DELETE, TOGGLE, ACTION, etc.)
- Form fields with names, types, required/optional status, defaults
- Query parameters
- File upload detection
Route Registry (registry.py)
Classifies routes into buckets for parametrized tests:
GET_NO_PARAMS: All GET routes without path parametersGET_WITH_PARAMS: All GET routes with{id}placeholdersPOST_CREATE: Routes ending in/createPOST_EDIT: Routes ending in/{id}/editPOST_DELETE: Routes ending in/{id}/deletePOST_ACTION: All other POST routes
Maps route prefixes to seed data via PREFIX_TO_SEED.
Form Factory (form_factory.py)
Generates valid POST form data automatically from introspected Form fields using multi-level heuristics:
- FK fields → seed UUIDs
- Date/time fields → generated dynamically
- Field name patterns → 40+ hardcoded values
- Type-based fallback
- Required string fallback
Seed Data (conftest.py)
Session-scoped fixture inserts 19 fixed-UUID entities:
- Core: domain, area, project, task
- CRM: contact, meeting, decision, appointment
- Content: note, list, link, weblink, weblink_folder
- System: capture, focus, process, process_step, process_run, time_budget, file
Fixed UUIDs (e.g., a0000000-0000-0000-0000-000000000001) ensure stable references.
25. Test Suite Reference
Test Types
Smoke Tests (test_smoke_dynamic.py):
- Verify all GET endpoints return 200
- Test detail routes with valid and invalid UUIDs
- Auto-parametrized from introspected routes
CRUD Tests (test_crud_dynamic.py):
- Verify POST create/edit/delete routes redirect (303)
- Auto-generate form data via form_factory
- Test order: create → edit → action → delete
Business Logic Tests (test_business_logic.py):
- 10+ test classes, ~50 tests total
- Timer constraints, task filtering, status transitions
- Capture conversions, meeting relationships
- Admin trash lifecycle, soft delete behavior
- Search behavior, sidebar integrity, focus workflow
Running Tests
docker exec lifeos-dev bash /app/tests/run_tests.sh # Full suite
docker exec lifeos-dev bash /app/tests/run_tests.sh smoke # GET endpoints
docker exec lifeos-dev bash /app/tests/run_tests.sh crud # POST endpoints
docker exec lifeos-dev bash /app/tests/run_tests.sh logic # Business logic
docker exec lifeos-dev bash /app/tests/run_tests.sh report # Route dump
docker exec lifeos-dev bash /app/tests/run_tests.sh fast # Smoke, stop first fail
docker exec lifeos-dev bash /app/tests/run_tests.sh -k "timer" # Keyword filter
Resetting Test DB After Schema Changes
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
Part 7: Operations
26. Deployment & Hot Reload
The dev container uses a volume mount (-v /opt/lifeos/dev:/app) plus uvicorn --reload. This means:
- Edit any file in
/opt/lifeos/dev/on the host - Uvicorn detects the change and restarts automatically
- No container rebuild needed
- No deploy script needed
Verification after changes:
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
27. Backup & Restore
Backup
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
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
28. Build Roadmap
Current State (as of 2026-03-02)
- Phase 1, Tier 3 complete. All 6 Tier 3 features built.
- 23 routers, 51 templates, 48 database tables
- 121+ discovered routes
What's Built
Core CRUD for: domains, areas, projects, tasks, notes, links, focus, capture, contacts, search, admin/trash, lists, files, meetings, decisions, weblinks, appointments, time_tracking, processes, calendar, time_budgets, eisenhower, history.
Working features: collapsible sidebar nav tree, task filtering/sorting/priority/status/context, daily focus, markdown notes, file uploads with 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, unified calendar view, Eisenhower matrix, time budgets with actual vs budgeted, process templates with runs, change history feed.
Remaining Roadmap
Tier 4:
- Releases/milestones CRUD
- Task 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
Known Traps & Gotchas
-
time_entrieshas noupdated_atcolumn. BaseRepository.soft_delete() will fail. Use direct SQL for delete/restore on this table. -
Junction tables use raw SQL, not BaseRepository. They have composite PKs and no
idcolumn. -
Timer constraint: Only one timer at a time.
time_entries WHERE end_at IS NULLdetects running timer. -
Nullable fields in BaseRepository: If a form field should allow NULL, add it to the
nullable_fieldsset. Otherwiseupdate()silently skips null values. -
Schema .sql files may be stale. Always verify against live DB with
\d table_name. -
No pagination. All list views load all rows. Fine at current data volume.
-
No CSRF protection. Single-user system.
-
SSL cert path uses
kasm.invixiom.com-0001(notkasm.invixiom.com). -
Test suite not fully green. Async event loop fixes and seed data corrections are in progress.
-
asyncpg requires native Python types. ISO date strings must be converted to
date/datetimeobjects before passing to queries. BaseRepository's_coerce_value()handles this automatically.
Document generated 2026-03-02. This is the single source of truth for rebuilding the Life OS system from ground zero.