Initial commit
This commit is contained in:
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
.env
|
||||
.git
|
||||
__pycache__
|
||||
*.pyc
|
||||
.pytest_cache
|
||||
docker-compose.yml
|
||||
.dockerignore
|
||||
.gitignore
|
||||
10
.env.example
Normal file
10
.env.example
Normal 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
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.venv/
|
||||
venv/
|
||||
443
CLAUDE.md
Normal file
443
CLAUDE.md
Normal 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
22
Dockerfile
Normal 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
0
core/__init__.py
Normal file
250
core/base_repository.py
Normal file
250
core/base_repository.py
Normal 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
47
core/database.py
Normal 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
72
core/sidebar.py
Normal 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
462
deploy-tests-fix.sh
Normal 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
1564
deploy-tests.sh
Normal file
File diff suppressed because it is too large
Load Diff
693
deploy-timer-buttons.sh
Normal file
693
deploy-timer-buttons.sh
Normal 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">■</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">▶</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">☑</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">■ 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">▶ 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
47
docker-compose.yml
Normal 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
|
||||
@@ -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
241
main.py
Normal 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)
|
||||
8633
project-docs/_liefos-dev-test_results1.txt
Normal file
8633
project-docs/_liefos-dev-test_results1.txt
Normal file
File diff suppressed because it is too large
Load Diff
349
project-docs/life-os-server-config.docx
Normal file
349
project-docs/life-os-server-config.docx
Normal 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
|
||||
3425
project-docs/lifeos-architecture.docx
Normal file
3425
project-docs/lifeos-architecture.docx
Normal file
File diff suppressed because it is too large
Load Diff
57
project-docs/lifeos-conversation-context-convo-test1.md
Normal file
57
project-docs/lifeos-conversation-context-convo-test1.md
Normal 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]
|
||||
42
project-docs/lifeos-conversation-context-convo4.md
Normal file
42
project-docs/lifeos-conversation-context-convo4.md
Normal 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]
|
||||
44
project-docs/lifeos-database-backup.md
Normal file
44
project-docs/lifeos-database-backup.md
Normal 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)
|
||||
329
project-docs/lifeos-development-status-convo4.md
Normal file
329
project-docs/lifeos-development-status-convo4.md
Normal 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
|
||||
261
project-docs/lifeos-development-status-test1.md
Normal file
261
project-docs/lifeos-development-status-test1.md
Normal 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.
|
||||
263
project-docs/lifeos-setup.sh
Normal file
263
project-docs/lifeos-setup.sh
Normal 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."
|
||||
}
|
||||
667
project-docs/lifeos-v2-migration-plan.docx
Normal file
667
project-docs/lifeos-v2-migration-plan.docx
Normal 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
|
||||
263
project-docs/lifeos_r0_to_r1_migration.sql
Normal file
263
project-docs/lifeos_r0_to_r1_migration.sql
Normal 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 $$;
|
||||
979
project-docs/lifeos_r1_full_schema.sql
Normal file
979
project-docs/lifeos_r1_full_schema.sql
Normal 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);
|
||||
358
project-docs/lifeos_schema_r1.sql
Normal file
358
project-docs/lifeos_schema_r1.sql
Normal 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);
|
||||
|
||||
122
project-docs/setup_dev_database.sh
Normal file
122
project-docs/setup_dev_database.sh
Normal 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 "=============================================="
|
||||
118
project-docs/setup_prod_database.sh
Normal file
118
project-docs/setup_prod_database.sh
Normal 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
6
pytest.ini
Normal 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
10
requirements.txt
Normal 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
0
routers/__init__.py
Normal file
129
routers/admin.py
Normal file
129
routers/admin.py
Normal 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
302
routers/appointments.py
Normal 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
122
routers/areas.py
Normal 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
138
routers/calendar.py
Normal 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
361
routers/capture.py
Normal 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
126
routers/contacts.py
Normal 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
218
routers/decisions.py
Normal 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
83
routers/domains.py
Normal 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
120
routers/eisenhower.py
Normal 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
398
routers/files.py
Normal 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
146
routers/focus.py
Normal 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
90
routers/history.py
Normal 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
156
routers/links.py
Normal 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
334
routers/lists.py
Normal 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
406
routers/meetings.py
Normal 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
195
routers/notes.py
Normal 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
569
routers/processes.py
Normal 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
403
routers/projects.py
Normal 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
237
routers/search.py
Normal 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
483
routers/tasks.py
Normal 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
189
routers/time_budgets.py
Normal 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
211
routers/time_tracking.py
Normal 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
369
routers/weblinks.py
Normal 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
97
setup-claude-code.sh
Normal 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
18321
smoke-results.txt
Normal file
File diff suppressed because it is too large
Load Diff
252
static/app.js
Normal file
252
static/app.js
Normal 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
1733
static/style.css
Normal file
File diff suppressed because it is too large
Load Diff
106
templates/appointment_detail.html
Normal file
106
templates/appointment_detail.html
Normal 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') %}
|
||||
– {{ 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 %}
|
||||
– {{ 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 %}
|
||||
· Updated {{ appointment.updated_at.strftime('%B %-d, %Y') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
130
templates/appointment_form.html
Normal file
130
templates/appointment_form.html
Normal 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 %}
|
||||
72
templates/appointments.html
Normal file
72
templates/appointments.html
Normal 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
22
templates/area_form.html
Normal 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
25
templates/areas.html
Normal 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
241
templates/base.html
Normal 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">■</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>⌘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
58
templates/calendar.html
Normal 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">← 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 →</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
110
templates/capture.html
Normal 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 · {{ 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 →</a>
|
||||
{% elif item.converted_to_type == 'link' %}
|
||||
<a href="/links" class="btn btn-ghost btn-xs">View →</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 →</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)">×</button></form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">📥</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 %}
|
||||
153
templates/capture_convert.html
Normal file
153
templates/capture_convert.html
Normal 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 %}
|
||||
25
templates/contact_detail.html
Normal file
25
templates/contact_detail.html
Normal 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 %}
|
||||
19
templates/contact_form.html
Normal file
19
templates/contact_form.html
Normal 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
25
templates/contacts.html
Normal 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">👤</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
140
templates/dashboard.html
Normal 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 %}
|
||||
55
templates/decision_detail.html
Normal file
55
templates/decision_detail.html
Normal 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 %}
|
||||
87
templates/decision_form.html
Normal file
87
templates/decision_form.html
Normal 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
53
templates/decisions.html
Normal 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">⚖</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 %}
|
||||
17
templates/domain_form.html
Normal file
17
templates/domain_form.html
Normal 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
26
templates/domains.html
Normal 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
184
templates/eisenhower.html
Normal 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 & 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 & 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 & 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 & 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 & 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 %}
|
||||
52
templates/file_preview.html
Normal file
52
templates/file_preview.html
Normal 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">📄</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 %}
|
||||
52
templates/file_upload.html
Normal file
52
templates/file_upload.html
Normal 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
91
templates/files.html
Normal 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 %} {{ 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">📁</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
109
templates/focus.html
Normal 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">×</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
32
templates/history.html
Normal 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
21
templates/link_form.html
Normal 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
25
templates/links.html
Normal 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">🔗</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
132
templates/list_detail.html
Normal 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">☐</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
84
templates/list_form.html
Normal 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
94
templates/lists.html
Normal 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">☰</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 %}
|
||||
237
templates/meeting_detail.html
Normal file
237
templates/meeting_detail.html
Normal 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
102
templates/meeting_form.html
Normal 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
46
templates/meetings.html
Normal 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">📅</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 %}
|
||||
26
templates/note_detail.html
Normal file
26
templates/note_detail.html
Normal 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
21
templates/note_form.html
Normal 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
25
templates/notes.html
Normal 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">📄</div><div class="empty-state-text">No notes yet</div><a href="/notes/create" class="btn btn-primary">Create Note</a></div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
133
templates/process_run_detail.html
Normal file
133
templates/process_run_detail.html
Normal 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 %}
|
||||
46
templates/process_runs.html
Normal file
46
templates/process_runs.html
Normal 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">▶</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
52
templates/processes.html
Normal 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">⚙</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 %}
|
||||
183
templates/processes_detail.html
Normal file
183
templates/processes_detail.html
Normal 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 %}
|
||||
57
templates/processes_form.html
Normal file
57
templates/processes_form.html
Normal 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
Reference in New Issue
Block a user