various enhancements for new tabs and bug fixes

This commit is contained in:
2026-03-02 17:35:00 +00:00
parent 9dedf6dbf2
commit cf84d6d2dd
32 changed files with 4501 additions and 296 deletions

View File

@@ -161,6 +161,7 @@ class BaseRepository:
"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():

View File

@@ -0,0 +1,337 @@
# Life OS - Claude Code Prompts for DEV Enhancements
**Target Environment:** DEV only (lifeos-dev.invixiom.com)
**Codebase Location:** `/opt/lifeos/dev/`
**Hot Reload:** Changes to files in `/opt/lifeos/dev/` are picked up automatically by uvicorn `--reload`. No container rebuild needed.
**Database:** `lifeos_dev` on container `lifeos-db` (PostgreSQL 16)
---
## PROMPT 1: Task Detail Page - Add Tabs
**Context:** The task detail page at `/tasks/{task_id}` currently shows task metadata but does NOT use the tabbed layout. The project detail page at `/projects/{project_id}` (template: `project_detail.html`) IS the reference implementation for tabbed detail views. It uses a `?tab=` query parameter to switch between tabs, with tab content rendered server-side via Jinja2 conditionals.
**Codebase facts:**
- Stack: Python 3.12, FastAPI, SQLAlchemy async, Jinja2 templates, vanilla JS/CSS
- Template to modify: `templates/task_detail.html`
- Router to modify: `routers/tasks.py` (the `task_detail` GET handler at `/tasks/{task_id}`)
- Reference template: `templates/project_detail.html` - copy the tab-strip HTML structure and `?tab=` query param pattern exactly
- CSS: `static/style.css` already has `.tab-strip` and `.tab-content` classes from the project detail implementation
- BaseRepository pattern: `core/base_repository.py` provides generic CRUD. All queries use `is_deleted = false` filtering automatically.
**Existing DB tables/junctions (already in schema, may not have app code):**
- `notes` - has `domain_id`, `project_id` columns. Notes don't have a `task_id` FK directly. You'll need to either add a `task_id` FK column to notes OR use a polymorphic junction (e.g., `note_tasks` or use `file_mappings` pattern with `context_type='task'`). Prefer adding a nullable `task_id UUID REFERENCES tasks(id)` column to the `notes` table for simplicity.
- `weblinks` - has `domain_id`, `project_id`. Same situation as notes - add nullable `task_id UUID REFERENCES tasks(id)`.
- `file_mappings` - polymorphic junction: `file_id, context_type, context_id`. Already supports `context_type='task'`. Files tab should query `file_mappings WHERE context_type='task' AND context_id={task_id}` joined to `files`.
- `lists` - has `domain_id`, `project_id`. Add nullable `task_id UUID REFERENCES tasks(id)`.
- `decisions` - has `domain_id`, `project_id`. Add nullable `task_id UUID REFERENCES tasks(id)`.
- `processes` / `process_runs` - These tables exist in the R1 schema but process CRUD is NOT yet built. The Processes tab should show: (a) a way to apply a process template to this task (create a process_run with context), and (b) list existing process_runs linked to this task. For now, since process CRUD doesn't exist yet, create the tab structure with an empty state placeholder that says "Process management coming soon" and a TODO comment in the code.
- `contact_tasks` - junction table already exists with columns: `contact_id, task_id, role, created_at`. This is the Contacts + Roles tab data source.
**What to build:**
1. **Migration SQL** - Create a migration file at `/opt/lifeos/migrations/` that adds:
- `ALTER TABLE notes ADD COLUMN task_id UUID REFERENCES tasks(id);`
- `ALTER TABLE weblinks ADD COLUMN task_id UUID REFERENCES tasks(id);`
- `ALTER TABLE lists ADD COLUMN task_id UUID REFERENCES tasks(id);`
- `ALTER TABLE decisions ADD COLUMN task_id UUID REFERENCES tasks(id);`
- Create indexes on these new FK columns.
- Apply to `lifeos_dev` database via: `docker exec -i lifeos-db psql -U postgres -d lifeos_dev < /opt/lifeos/migrations/NNNN_task_detail_tabs.sql`
2. **Router** (`routers/tasks.py`) - Modify the task detail GET handler:
- Accept `tab` query parameter (default: "overview")
- Tabs: overview, notes, weblinks, files, lists, decisions, processes, contacts
- For each tab, query the relevant data and pass to template context
- For contacts tab: query `contact_tasks` joined to `contacts` WHERE `task_id = {id}`
- For files tab: query `file_mappings` WHERE `context_type='task' AND context_id={id}` joined to `files`
- For notes/weblinks/lists/decisions: query WHERE `task_id = {id} AND is_deleted = false`
3. **Template** (`templates/task_detail.html`) - Restructure to match `project_detail.html` tab pattern:
- Keep existing task header (title, metadata, action buttons including timer)
- Add tab-strip below header with all tab names
- Active tab highlighted via CSS class
- Tab links use `?tab=tabname` pattern
- Each tab section wrapped in Jinja2 `{% if tab == 'notes' %}...{% endif %}` blocks
- Overview tab contains what's currently shown (description, subtasks, metadata)
- Each entity tab shows a list of related items with an "+ Add" button
- Contacts tab shows contact name + role with ability to add/remove
4. **Contact + Role management on tasks:**
- Add endpoint `POST /tasks/{task_id}/contacts/add` accepting `contact_id` and `role` form fields. Insert into `contact_tasks`.
- Add endpoint `POST /tasks/{task_id}/contacts/{contact_id}/remove` to delete from `contact_tasks`.
- The contacts tab should show a form with a contact dropdown (all non-deleted contacts) and a role text input, plus a list of currently linked contacts with remove buttons.
- Role should be a free-text field (not a fixed set yet - we'll define role sets later).
5. **Update note/weblink/list/decision create forms** to accept optional `task_id` and pre-fill it when creating from within a task detail page. The "+ Add" buttons on each tab should link to the respective create form with `?task_id={task_id}` pre-filled.
**Do NOT:**
- Touch production database or files
- Build full process CRUD (just placeholder tab)
- Change the existing task list page (`tasks.html`)
- Add any npm/frontend build tooling
**Test:** After implementation, verify at `https://lifeos-dev.invixiom.com/tasks/{any_task_id}` that all tabs render, switching works via URL param, and the contacts add/remove flow works.
---
## PROMPT 2: Project Detail Page - Add Missing Tabs
**Context:** The project detail page at `/projects/{project_id}` already has a working tabbed layout with tabs for Tasks, Notes, Links (weblinks). It needs additional tabs for: Files, Lists, Processes, Contacts + Roles.
**Codebase facts:**
- Template: `templates/project_detail.html` (already has tab-strip pattern)
- Router: `routers/projects.py` (the detail GET handler already accepts `?tab=` param)
- Existing tabs: tasks, notes, links/weblinks
- `file_mappings` supports `context_type='project'` already
- `contact_projects` junction table exists: `contact_id, project_id, role, created_at`
- `lists` table has `project_id` FK already
- Process CRUD not yet built
**What to build:**
1. **Router** (`routers/projects.py`) - Extend the detail handler:
- Add tab cases for: files, lists, processes, contacts
- Files: query `file_mappings WHERE context_type='project' AND context_id={project_id}` joined to `files`
- Lists: query `lists WHERE project_id={id} AND is_deleted=false`
- Processes: placeholder (empty state)
- Contacts: query `contact_projects` joined to `contacts` WHERE `project_id={id}`
2. **Template** (`templates/project_detail.html`) - Add new tabs to existing tab-strip:
- Files tab: list of attached files with upload link (`/files/upload?context_type=project&context_id={project_id}`)
- Lists tab: list of project lists with link to create (`/lists/create?project_id={project_id}`)
- Processes tab: empty state placeholder "Process management coming soon"
- Contacts tab: same pattern as Task contacts - dropdown + role input + list with remove
3. **Contact + Role management on projects:**
- `POST /projects/{project_id}/contacts/add` - insert into `contact_projects`
- `POST /projects/{project_id}/contacts/{contact_id}/remove` - delete from `contact_projects`
- Same UI pattern as task contacts tab
**Do NOT:**
- Modify existing working tabs (tasks, notes, links)
- Build process CRUD
**Test:** Verify at `https://lifeos-dev.invixiom.com/projects/{any_project_id}` that new tabs appear and function.
---
## PROMPT 3: Meeting Detail Page - Add Tabs
**Context:** The meeting detail page at `/meetings/{meeting_id}` exists but may not have the full tabbed layout. It needs tabs for: Notes, Weblinks, Files, Lists, Processes, Contacts + Roles. Meetings already have an action items / tasks relationship via the `meeting_tasks` junction table.
**Codebase facts:**
- Template: `templates/meeting_detail.html`
- Router: `routers/meetings.py`
- `contact_meetings` junction exists: `contact_id, meeting_id, role, created_at` (role values: organizer|attendee|optional)
- `meeting_tasks` junction exists: `meeting_id, task_id, source` (source: discussed|action_item)
- `meetings` table has `notes_body` column for inline meeting notes
- Notes, weblinks, lists don't have a `meeting_id` FK column
**What to build:**
1. **Migration SQL** - Add to same or new migration file:
- `ALTER TABLE notes ADD COLUMN meeting_id UUID REFERENCES meetings(id);`
- `ALTER TABLE weblinks ADD COLUMN meeting_id UUID REFERENCES meetings(id);`
- `ALTER TABLE lists ADD COLUMN meeting_id UUID REFERENCES meetings(id);`
- Indexes on new columns.
- Apply to `lifeos_dev`.
2. **Router** (`routers/meetings.py`) - Add/extend detail handler with `?tab=` param:
- Tabs: overview (existing content: agenda, notes_body, action items), notes, weblinks, files, lists, processes, contacts
- Query patterns same as tasks/projects for each tab type
- Files via `file_mappings WHERE context_type='meeting'`
- Contacts via `contact_meetings` joined to `contacts`
3. **Template** (`templates/meeting_detail.html`) - Restructure to tab layout matching project_detail.html pattern:
- Overview tab: existing meeting content (agenda, meeting notes, action items list)
- All other tabs: same list + add pattern as task/project detail
4. **Contact + Role management on meetings:**
- `POST /meetings/{meeting_id}/contacts/add` - insert into `contact_meetings`, role defaults to "attendee"
- `POST /meetings/{meeting_id}/contacts/{contact_id}/remove` - delete from `contact_meetings`
- Role dropdown with predefined values: organizer, attendee, optional
**Test:** Verify at `https://lifeos-dev.invixiom.com/meetings/{any_meeting_id}`.
---
## PROMPT 4: Lists Page - Dynamic Domain/Project Filter + Contact Roles
**Context:** The lists page at `/lists/` has domain and project dropdown filters. Currently the project dropdown shows ALL projects regardless of which domain is selected. It should filter dynamically.
**Codebase facts:**
- Template: `templates/lists.html`
- Router: `routers/lists.py` (or wherever list CRUD lives)
- The filter uses HTML `<select>` elements that auto-submit on change (pattern from `app.js`)
- `projects` table has `domain_id` FK (nullable - some projects may have domain_id = NULL via unassigned area)
- `areas` table has `domain_id` FK
- `contact_lists` junction exists: `contact_id, list_id, role, created_at`
**What to build:**
1. **Dynamic project filtering by domain** - Two approaches, pick the simpler one:
- **Option A (recommended - vanilla JS):** Add a small JS block to `lists.html` (or `app.js`) that intercepts the domain dropdown `change` event. When domain changes, fetch projects for that domain via a new lightweight API endpoint `GET /api/projects?domain_id={id}` that returns JSON `[{id, name}]`. Rebuild the project `<select>` options dynamically. Include projects where `domain_id` matches OR where `domain_id IS NULL` (unassigned). If no domain selected, show all projects.
- **Option B (server-side only):** On domain change, the form auto-submits (existing behavior). The router filters the project dropdown options server-side based on the selected domain_id and passes filtered projects to the template. This is simpler but causes a page reload.
- Go with Option A for better UX. Create the API endpoint in the lists router or a shared API router.
2. **Contact + Role management on lists:**
- On the list detail page (`list_detail.html`), add a Contacts section (or tab if it has tabs).
- `POST /lists/{list_id}/contacts/add` - insert into `contact_lists`
- `POST /lists/{list_id}/contacts/{contact_id}/remove` - delete from `contact_lists`
- Same UI pattern: contact dropdown + role text input + list with remove buttons.
**Test:** Go to `https://lifeos-dev.invixiom.com/lists/`, select a domain, verify the project dropdown updates to only show projects in that domain plus unassigned ones. Test adding/removing contacts on a list detail page.
---
## PROMPT 5: Eisenhower Page - Dynamic Height + Filters
**Context:** The Eisenhower matrix page at `/eisenhower/` displays tasks in a 2x2 grid (Important+Urgent, Important+Not Urgent, Not Important+Urgent, Not Important+Not Urgent). Currently the quadrant cards have a fixed minimum height which wastes space or clips content.
**Codebase facts:**
- Template: `templates/eisenhower.html` (if it exists) or it may be part of another template
- Router: `routers/eisenhower.py` or may be in `routers/tasks.py`
- CSS in `static/style.css`
- Current quadrant logic: priority 1-2 = Important, 3-4 = Not Important. Due date <= 7 days = Urgent, > 7 days or null = Not Urgent.
- Tasks are filtered: only `status IN ('open', 'in_progress', 'blocked')` and `is_deleted = false`
**What to build:**
1. **Dynamic height for quadrant cards:**
- Remove any `min-height` or fixed `height` CSS on the Eisenhower quadrant containers.
- Use CSS that allows quadrants to grow with content: `min-height: 200px` (reasonable minimum) but no `max-height` restriction. Or use CSS Grid with `grid-template-rows: auto auto` so rows size to content.
- Each card within a quadrant should be compact (task title, priority dot, due date - single line or two lines max).
- If a quadrant has many tasks, it grows. If empty, it shows a small empty state.
2. **Add filters:**
- Add a filter bar above the 2x2 grid with dropdowns for:
- **Domain** - filter tasks by domain_id
- **Project** - filter tasks by project_id (dynamic based on domain selection, same pattern as Prompt 4)
- **Status** - filter by status (open, in_progress, blocked)
- **Context** - filter by context (GTD execution context from context_types table)
- Filters use query parameters: `/eisenhower/?domain_id=X&project_id=Y&status=Z&context=W`
- Router applies these filters to the task query before distributing into quadrants.
- Use the same auto-submit-on-change pattern used elsewhere in the app.
**Test:** Verify at `https://lifeos-dev.invixiom.com/eisenhower/` that quadrants resize with content and filters narrow the displayed tasks.
---
## PROMPT 6: Focus Page - Add Domain/Area/Project Filters
**Context:** The focus page at `/focus/` shows the daily focus list (tasks selected for today). Currently there are no filters to narrow which available tasks are shown for adding to focus.
**Codebase facts:**
- Template: `templates/focus.html`
- Router: `routers/focus.py`
- The page has two sections: (1) Today's focus items (tasks already added), (2) Available tasks to add
- `daily_focus` table: `id, task_id, focus_date, completed, sort_order, is_deleted, created_at`
- Tasks have `domain_id`, `project_id` FKs. Tasks also have `area_id` FK.
**What to build:**
1. **Filter bar for available tasks section:**
- Add dropdowns above the "Available Tasks" section for: Domain, Area (filtered by selected domain), Project (filtered by selected domain/area)
- Same dynamic filtering pattern as Prompts 4 and 5
- Filters apply only to the "available tasks to add" list, NOT to the already-focused items
- Query params: `/focus/?domain_id=X&area_id=Y&project_id=Z&focus_date=YYYY-MM-DD`
2. **Router changes:**
- Accept domain_id, area_id, project_id query params
- Apply as WHERE clauses when querying available (unfocused) tasks
- Pass filter values + dropdown options to template context
**Test:** Verify at `https://lifeos-dev.invixiom.com/focus/` that filter dropdowns appear and narrow the available tasks list.
---
## PROMPT 7: Change History (Recently Changed Items)
**Context:** We need a "Change History" view so the user can see recently modified items across the entire system. Every table in Life OS has `created_at` and `updated_at` TIMESTAMPTZ columns (except `time_entries` which is missing `updated_at`). This gives us a simple approach: query `updated_at` across all entity tables and show a reverse-chronological feed.
**Codebase facts:**
- All main entity tables have `updated_at`: domains, areas, projects, tasks, notes, contacts, meetings, decisions, lists, weblinks, appointments, links, files, processes, capture
- `time_entries` does NOT have `updated_at` - exclude from change history
- BaseRepository handles all CRUD and sets `updated_at = now()` on every update
- Sidebar navigation is built in `core/sidebar.py`
**What to build:**
1. **New router:** Create `routers/history.py` with prefix `/history`
- `GET /history/` - renders the change history page
- Query: For each entity table, `SELECT id, 'entity_type' as type, title/name as label, updated_at, created_at FROM {table} WHERE is_deleted = false ORDER BY updated_at DESC LIMIT 20`
- Union all results, sort by `updated_at DESC`, take top 50 (or paginate)
- For each item, determine if it was "created" (updated_at = created_at or within 1 second) or "modified"
- Pass to template as a list of `{type, id, label, updated_at, action}` dicts
2. **Template:** Create `templates/history.html`
- Page title: "Change History" or "Recent Changes"
- Filter by: entity type dropdown, date range
- Each row shows: timestamp, action icon (created/modified), entity type badge, item name as clickable link to detail page, relative time ("2 minutes ago", "yesterday")
- Use the standard list-row styling
3. **Sidebar link:** Add "History" to the sidebar navigation in `core/sidebar.py` (or `base.html` directly), in the utility section near Search/Capture/Admin.
4. **Register router** in `main.py`: `app.include_router(history.router)`
**Architecture note:** This is NOT a full audit log with field-level diffs. It's a simple "what changed recently" view derived from `updated_at` timestamps. A full audit log (with before/after values) would require triggers or middleware - defer that to a later phase.
**Test:** Verify at `https://lifeos-dev.invixiom.com/history/` that recently created/modified items appear in reverse chronological order with working links.
---
## PROMPT 8: Fix Search - Partial Word Matching
**Context:** The global search currently uses PostgreSQL `tsvector/tsquery` full-text search. This is great for natural language search but does NOT support partial word matching. Searching for "Sys" does not return items containing "System" because tsquery requires complete lexemes.
**Codebase facts:**
- Search router: `routers/search.py`
- API endpoint: `GET /search/api?q=X&entity_type=Y&limit=Z`
- Page endpoint: `GET /search/?q=X`
- Every searchable table has a `search_vector TSVECTOR` column with GIN index
- Search triggers maintain the tsvector automatically
- Current query pattern likely uses `plainto_tsquery('english', query)` or `to_tsquery`
**The fix:**
The search needs to support partial prefix matching. PostgreSQL supports this via `:*` suffix on tsquery terms.
1. **Modify the search query building** in `routers/search.py`:
- Instead of `plainto_tsquery('english', query)`, build a prefix query
- For a search term like "Sys", construct: `to_tsquery('english', 'Sys:*')`
- For multi-word queries like "Sys Admin", construct: `to_tsquery('english', 'Sys:* & Admin:*')` (AND all terms with prefix matching)
- Implementation: split the query string on whitespace, strip non-alphanumeric chars from each term, append `:*` to each, join with ` & `
- Handle edge cases: empty query, single character (skip search for < 2 chars), special characters
2. **Also add an ILIKE fallback** for when tsvector doesn't match (tsvector strips stop words and stems, so very short or unusual terms might not match):
- After the tsvector query, if results are sparse (< 3 results), do a supplemental `WHERE title ILIKE '%{query}%' OR name ILIKE '%{query}%'` query
- Deduplicate results (tsvector results first, then ILIKE additions)
- This ensures "Sys" matches "System" even if the stemmer does something unexpected
3. **Update the search query for EACH entity** that's searched. The pattern should be consistent - build a helper function like:
```python
def build_search_condition(query: str):
terms = query.strip().split()
if not terms:
return None
tsquery_str = ' & '.join(f"{term}:*" for term in terms if len(term) >= 2)
return tsquery_str
```
**Do NOT:**
- Change the search_vector triggers or GIN indexes (they're fine)
- Add any new database tables
- Change the search UI/template (just the backend query logic)
**Test:** Go to `https://lifeos-dev.invixiom.com/search/?q=Sys` and verify it returns items containing "System", "Systematic", "Sys" etc. Test with "Inv" to match "Invoice", "Investment" etc.
---
## General Notes for All Prompts
- **Git:** After each prompt's changes are verified working, run: `cd /opt/lifeos/dev && git add . && git commit -m "descriptive message" && git push origin main`
- **Database migrations:** Always apply to `lifeos_dev` only. Command pattern: `docker exec -i lifeos-db psql -U postgres -d lifeos_dev < migration_file.sql`
- **Restart if needed:** `docker restart lifeos-dev` (though hot reload should handle most changes)
- **Logs:** `docker logs lifeos-dev --tail 30` to debug errors
- **Contact role pattern:** For now, roles are free-text on tasks/projects/lists. On meetings, roles are constrained to: organizer, attendee, optional. This will be standardized later with a defined role set.
- **Empty states:** Every tab/section that can be empty should show a clean empty state message with a CTA button to add the first item.

View File

@@ -43,6 +43,7 @@ from routers import (
calendar as calendar_router,
time_budgets as time_budgets_router,
eisenhower as eisenhower_router,
history as history_router,
)
@@ -201,3 +202,4 @@ app.include_router(processes_router.router)
app.include_router(calendar_router.router)
app.include_router(time_budgets_router.router)
app.include_router(eisenhower_router.router)
app.include_router(history_router.router)

File diff suppressed because it is too large Load Diff

View File

@@ -56,6 +56,7 @@ async def list_decisions(
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)
@@ -72,6 +73,7 @@ async def create_form(
"page_title": "New Decision", "active_nav": "decisions",
"item": None,
"prefill_meeting_id": meeting_id or "",
"prefill_task_id": task_id or "",
})
@@ -84,6 +86,7 @@ async def create_decision(
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),
):
@@ -96,10 +99,14 @@ async def create_decision(
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)

View File

@@ -4,8 +4,10 @@ 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"])
@@ -15,11 +17,36 @@ 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)
result = await db.execute(text("""
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,
@@ -27,10 +54,9 @@ async def eisenhower_matrix(
FROM tasks t
LEFT JOIN projects p ON t.project_id = p.id
LEFT JOIN domains d ON t.domain_id = d.id
WHERE t.is_deleted = false
AND t.status IN ('open', 'in_progress', 'blocked')
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
@@ -39,10 +65,10 @@ async def eisenhower_matrix(
urgent_cutoff = today + timedelta(days=7)
quadrants = {
"do_first": [], # Urgent + Important
"schedule": [], # Not Urgent + Important
"delegate": [], # Urgent + Not Important
"eliminate": [], # Not Urgent + Not Important
"do_first": [],
"schedule": [],
"delegate": [],
"eliminate": [],
}
for t in tasks:
@@ -64,6 +90,17 @@ async def eisenhower_matrix(
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,
@@ -71,6 +108,13 @@ async def eisenhower_matrix(
"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",
})

View File

@@ -17,7 +17,14 @@ templates = Jinja2Templates(directory="templates")
@router.get("/")
async def focus_view(request: Request, focus_date: Optional[str] = None, db: AsyncSession = Depends(get_db)):
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()
@@ -36,30 +43,57 @@ async def focus_view(request: Request, focus_date: Optional[str] = None, db: Asy
items = [dict(r._mapping) for r in result]
# Available tasks to add (open, not already in today's focus)
result = await db.execute(text("""
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 t.is_deleted = false AND t.status NOT IN ('done', 'cancelled')
AND t.id NOT IN (
SELECT task_id FROM daily_focus
WHERE focus_date = :target_date AND is_deleted = false
)
WHERE {avail_sql}
ORDER BY t.priority ASC, t.due_date ASC NULLS LAST
LIMIT 50
"""), {"target_date": target_date})
"""), 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",
})

91
routers/history.py Normal file
View File

@@ -0,0 +1,91 @@
"""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"),
("weblinks", "label", "Weblink", "/weblinks"),
("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",
})

View File

@@ -70,6 +70,8 @@ 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)
@@ -87,6 +89,8 @@ async def create_form(
"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 "",
})
@@ -97,6 +101,8 @@ async def create_list(
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),
@@ -112,10 +118,18 @@ async def create_list(
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)
@@ -156,10 +170,28 @@ async def list_detail(list_id: str, request: Request, db: AsyncSession = Depends
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",
})
@@ -273,3 +305,30 @@ async def edit_item(
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)

View File

@@ -107,61 +107,118 @@ async def create_meeting(
@router.get("/{meeting_id}")
async def meeting_detail(meeting_id: str, request: Request, db: AsyncSession = Depends(get_db)):
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)
# Action items (tasks linked to this meeting)
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]
# Overview data (always needed for overview tab)
action_items = []
decisions = []
domains = []
tab_data = []
all_contacts = []
# Notes linked to this meeting
result = await db.execute(text("""
SELECT * FROM notes
WHERE meeting_id = :mid AND is_deleted = false
ORDER BY created_at
"""), {"mid": meeting_id})
meeting_notes = [dict(r._mapping) for r in result]
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]
# 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]
# Attendees
result = await db.execute(text("""
SELECT c.*, cm.role FROM contact_meetings cm
JOIN contacts c ON cm.contact_id = c.id
WHERE cm.meeting_id = :mid AND c.is_deleted = false
ORDER BY c.first_name
"""), {"mid": meeting_id})
attendees = [dict(r._mapping) for r in result]
# Domains for action item creation
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
# 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 == "weblinks":
result = await db.execute(text("""
SELECT * FROM weblinks
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 == "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"),
("weblinks", "SELECT count(*) FROM weblinks 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"),
("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, "meeting_notes": meeting_notes,
"decisions": decisions, "attendees": attendees,
"domains": domains,
"action_items": action_items, "decisions": decisions,
"domains": domains, "tab": tab, "tab_data": tab_data,
"all_contacts": all_contacts, "counts": counts,
"page_title": item["title"], "active_nav": "meetings",
})
@@ -266,3 +323,30 @@ async def create_action_item(
"""), {"mid": meeting_id, "tid": task["id"]})
return RedirectResponse(url=f"/meetings/{meeting_id}", 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)

View File

@@ -61,6 +61,8 @@ 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)
@@ -75,6 +77,8 @@ async def create_form(
"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 "",
})
@@ -84,6 +88,8 @@ async def create_note(
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),
@@ -96,9 +102,17 @@ async def create_note(
}
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)

View File

@@ -2,7 +2,7 @@
from fastapi import APIRouter, Request, Form, Depends
from fastapi.templating import Jinja2Templates
from fastapi.responses import RedirectResponse
from fastapi.responses import RedirectResponse, JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from typing import Optional
@@ -15,6 +15,27 @@ 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,
@@ -151,7 +172,7 @@ async def project_detail(
row = result.first()
area = dict(row._mapping) if row else None
# Tasks for this project
# 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
@@ -161,29 +182,92 @@ async def project_detail(
"""), {"pid": project_id})
tasks = [dict(r._mapping) for r in result]
# Notes
result = await db.execute(text("""
SELECT * FROM notes WHERE project_id = :pid AND is_deleted = false
ORDER BY sort_order, created_at DESC
"""), {"pid": project_id})
notes = [dict(r._mapping) for r in result]
# Links
result = await db.execute(text("""
SELECT * FROM links WHERE project_id = :pid AND is_deleted = false
ORDER BY sort_order, created_at
"""), {"pid": project_id})
links = [dict(r._mapping) for r in result]
# Progress
total = len(tasks)
done = len([t for t in tasks if t["status"] == "done"])
progress = round((done / total * 100) if total > 0 else 0)
# Tab-specific data
notes = []
links = []
tab_data = []
all_contacts = []
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 == "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"),
("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, "counts": counts,
"progress": progress, "task_count": total, "done_count": done,
"tab": tab,
"page_title": item["name"], "active_nav": "projects",
@@ -246,3 +330,30 @@ 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)
# ---- 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)

View File

@@ -1,5 +1,6 @@
"""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
@@ -14,174 +15,162 @@ router = APIRouter(prefix="/search", tags=["search"])
templates = Jinja2Templates(directory="templates")
# Entity search configs: (table, title_col, subtitle_query, url_pattern, icon)
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",
"query": """
SELECT t.id, t.title as name, t.status,
d.name as domain_name, p.name as project_name,
ts_rank(t.search_vector, websearch_to_tsquery('english', :q)) as rank
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.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/tasks/{id}",
"icon": "task",
"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",
"query": """
SELECT p.id, p.name, p.status,
d.name as domain_name, NULL as project_name,
ts_rank(p.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM projects p
LEFT JOIN domains d ON p.domain_id = d.id
WHERE p.is_deleted = false AND p.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/projects/{id}",
"icon": "project",
"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",
"query": """
SELECT n.id, n.title as name, NULL as status,
d.name as domain_name, p.name as project_name,
ts_rank(n.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM notes n
LEFT JOIN domains d ON n.domain_id = d.id
LEFT JOIN projects p ON n.project_id = p.id
WHERE n.is_deleted = false AND n.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/notes/{id}",
"icon": "note",
"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",
"query": """
SELECT c.id, (c.first_name || ' ' || coalesce(c.last_name, '')) as name,
NULL as status, c.company as domain_name, NULL as project_name,
ts_rank(c.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM contacts c
WHERE c.is_deleted = false AND c.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/contacts/{id}",
"icon": "contact",
"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",
"query": """
SELECT l.id, l.label as name, NULL as status,
d.name as domain_name, p.name as project_name,
ts_rank(l.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM links l
LEFT JOIN domains d ON l.domain_id = d.id
LEFT JOIN projects p ON l.project_id = p.id
WHERE l.is_deleted = false AND l.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/links",
"icon": "link",
"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",
"query": """
SELECT l.id, l.name, NULL as status,
d.name as domain_name, p.name as project_name,
ts_rank(l.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM lists l
LEFT JOIN domains d ON l.domain_id = d.id
LEFT JOIN projects p ON l.project_id = p.id
WHERE l.is_deleted = false AND l.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/lists/{id}",
"icon": "list",
"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",
"query": """
SELECT m.id, m.title as name, m.status,
NULL as domain_name, NULL as project_name,
ts_rank(m.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM meetings m
WHERE m.is_deleted = false AND m.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/meetings/{id}",
"icon": "meeting",
"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",
"query": """
SELECT d.id, d.title as name, d.status,
NULL as domain_name, NULL as project_name,
ts_rank(d.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM decisions d
WHERE d.is_deleted = false AND d.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/decisions/{id}",
"icon": "decision",
"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": "weblinks",
"label": "Weblinks",
"query": """
SELECT w.id, w.label as name, NULL as status,
NULL as domain_name, NULL as project_name,
ts_rank(w.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM weblinks w
WHERE w.is_deleted = false AND w.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/weblinks",
"icon": "weblink",
"type": "weblinks", "label": "Weblinks", "table": "weblinks", "alias": "w",
"name_col": "w.label", "status_col": "NULL",
"joins": "",
"domain_col": "NULL", "project_col": "NULL",
"url": "/weblinks", "icon": "weblink",
},
{
"type": "processes",
"label": "Processes",
"query": """
SELECT p.id, p.name, p.status,
p.category as domain_name, NULL as project_name,
ts_rank(p.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM processes p
WHERE p.is_deleted = false AND p.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/processes/{id}",
"icon": "process",
"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",
"query": """
SELECT a.id, a.title as name, NULL as status,
a.location as domain_name, NULL as project_name,
ts_rank(a.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM appointments a
WHERE a.is_deleted = false AND a.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/appointments/{id}",
"icon": "appointment",
"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),
@@ -190,33 +179,30 @@ async def search_api(
db: AsyncSession = Depends(get_db),
):
"""JSON search endpoint for the Cmd/K modal."""
if not q or not q.strip():
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:
try:
result = await db.execute(text(entity["query"]), {"q": q.strip(), "lim": limit})
rows = [dict(r._mapping) for r in result]
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"],
})
except Exception:
# Table might not have search_vector yet, skip silently
continue
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)
@@ -234,24 +220,22 @@ async def search_page(
sidebar = await get_sidebar_data(db)
results = []
if q and q.strip():
if q and q.strip() and len(q.strip()) >= 2:
tsquery_str = build_prefix_tsquery(q)
for entity in SEARCH_ENTITIES:
try:
result = await db.execute(text(entity["query"]), {"q": q.strip(), "lim": 10})
rows = [dict(r._mapping) for r in result]
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"],
})
except Exception:
continue
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,

View File

@@ -186,7 +186,11 @@ async def create_task(
@router.get("/{task_id}")
async def task_detail(task_id: str, request: Request, db: AsyncSession = Depends(get_db)):
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)
@@ -212,19 +216,101 @@ async def task_detail(task_id: str, request: Request, db: AsyncSession = Depends
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]
# 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 == "weblinks":
result = await db.execute(text("""
SELECT * FROM weblinks 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"),
("weblinks", "SELECT count(*) FROM weblinks 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,
"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",
})
@@ -368,3 +454,30 @@ async def quick_add(
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)

View File

@@ -78,6 +78,8 @@ async def list_weblinks(
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)
@@ -92,6 +94,8 @@ async def create_form(
"page_title": "New Weblink", "active_nav": "weblinks",
"item": None,
"prefill_folder_id": folder_id or "",
"prefill_task_id": task_id or "",
"prefill_meeting_id": meeting_id or "",
})
@@ -102,11 +106,17 @@ async def create_weblink(
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("weblinks", 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()]
@@ -119,6 +129,10 @@ async def create_weblink(
VALUES (:fid, :wid) ON CONFLICT DO NOTHING
"""), {"fid": folder_id, "wid": weblink["id"]})
if task_id and task_id.strip():
return RedirectResponse(url=f"/tasks/{task_id}?tab=weblinks", status_code=303)
if meeting_id and meeting_id.strip():
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=weblinks", 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)

View File

@@ -1451,9 +1451,8 @@ a:hover { color: var(--accent-hover); }
.eisenhower-grid {
display: grid;
grid-template-columns: 32px 1fr 1fr;
grid-template-rows: 1fr 1fr auto;
grid-template-rows: auto auto auto;
gap: 12px;
min-height: 60vh;
}
.eisenhower-y-label {
@@ -1488,9 +1487,9 @@ a:hover { color: var(--accent-hover); }
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 16px;
min-height: 200px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.eisenhower-q1 { border-top: 3px solid var(--red); }

View File

@@ -131,6 +131,10 @@
</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

View File

@@ -77,9 +77,10 @@
</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="{{ '/decisions/' ~ item.id if item else '/decisions' }}" class="btn btn-secondary">Cancel</a>
<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>

View File

@@ -5,6 +5,34 @@
<span class="text-muted">{{ total }} open tasks classified by priority &amp; urgency</span>
</div>
<!-- Filters -->
<form class="filters-bar" method="get" action="/eisenhower" id="eis-filters">
<select name="domain_id" class="filter-select" id="eis-domain" onchange="this.form.submit()">
<option value="">All Domains</option>
{% for d in domains %}
<option value="{{ d.id }}" {{ 'selected' if current_domain_id == d.id|string }}>{{ d.name }}</option>
{% endfor %}
</select>
<select name="project_id" class="filter-select" id="eis-project" onchange="this.form.submit()">
<option value="">All Projects</option>
{% for p in projects %}
<option value="{{ p.id }}" {{ 'selected' if current_project_id == p.id|string }}>{{ p.name }}</option>
{% endfor %}
</select>
<select name="status" class="filter-select" onchange="this.form.submit()">
<option value="">All Statuses</option>
<option value="open" {{ 'selected' if current_status == 'open' }}>Open</option>
<option value="in_progress" {{ 'selected' if current_status == 'in_progress' }}>In Progress</option>
<option value="blocked" {{ 'selected' if current_status == 'blocked' }}>Blocked</option>
</select>
<select name="context" class="filter-select" onchange="this.form.submit()">
<option value="">All Contexts</option>
{% for ct in context_types %}
<option value="{{ ct.value }}" {{ 'selected' if current_context == ct.value }}>{{ ct.label }}</option>
{% endfor %}
</select>
</form>
<div class="eisenhower-grid">
<!-- Axis labels -->
<div class="eisenhower-y-label">
@@ -126,4 +154,31 @@
<div class="eisenhower-x-label">Not Urgent</div>
</div>
<script>
(function() {
var domainSel = document.getElementById('eis-domain');
var projectSel = document.getElementById('eis-project');
var currentProjectId = '{{ current_project_id }}';
var form = document.getElementById('eis-filters');
domainSel.addEventListener('change', function() {
var did = domainSel.value;
if (!did) { form.submit(); return; }
fetch('/projects/api/by-domain?domain_id=' + encodeURIComponent(did))
.then(function(r) { return r.json(); })
.then(function(projects) {
projectSel.innerHTML = '<option value="">All Projects</option>';
projects.forEach(function(p) {
var opt = document.createElement('option');
opt.value = p.id;
opt.textContent = p.name;
if (p.id === currentProjectId) opt.selected = true;
projectSel.appendChild(opt);
});
form.submit();
})
.catch(function() { form.submit(); });
});
})();
</script>
{% endblock %}

View File

@@ -37,9 +37,31 @@
{% endif %}
<!-- Add task to focus -->
{% if available_tasks %}
<div class="card mt-4">
<div class="card-header"><h2 class="card-title">Add to Focus</h2></div>
<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>
@@ -52,7 +74,36 @@
<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>
{% endif %}
<script>
(function() {
var domainSel = document.getElementById('focus-domain');
var projectSel = document.getElementById('focus-project');
var currentProjectId = '{{ current_project_id }}';
var form = document.getElementById('focus-filters');
domainSel.addEventListener('change', function() {
var did = domainSel.value;
if (!did) { form.submit(); return; }
fetch('/projects/api/by-domain?domain_id=' + encodeURIComponent(did))
.then(function(r) { return r.json(); })
.then(function(projects) {
projectSel.innerHTML = '<option value="">All Projects</option>';
projects.forEach(function(p) {
var opt = document.createElement('option');
opt.value = p.id;
opt.textContent = p.name;
if (p.id === currentProjectId) opt.selected = true;
projectSel.appendChild(opt);
});
form.submit();
})
.catch(function() { form.submit(); });
});
})();
</script>
{% endblock %}

32
templates/history.html Normal file
View File

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

View File

@@ -95,4 +95,38 @@
<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 %}

View File

@@ -73,9 +73,11 @@
</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="{{ '/lists/' ~ item.id if item else '/lists' }}" class="btn btn-secondary">Cancel</a>
<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>

View File

@@ -6,14 +6,14 @@
</div>
<!-- Filters -->
<form class="filters-bar" method="get" action="/lists">
<select name="domain_id" class="filter-select" onchange="this.form.submit()">
<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" onchange="this.form.submit()">
<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>
@@ -57,4 +57,38 @@
<a href="/lists/create" class="btn btn-primary">Create First List</a>
</div>
{% endif %}
<script>
(function() {
var domainSel = document.getElementById('domain-filter');
var projectSel = document.getElementById('project-filter');
var currentProjectId = '{{ current_project_id }}';
domainSel.addEventListener('change', function() {
var did = domainSel.value;
if (!did) {
// No domain filter - submit immediately, server returns all projects
document.getElementById('list-filters').submit();
return;
}
// Fetch projects for this domain
fetch('/projects/api/by-domain?domain_id=' + encodeURIComponent(did))
.then(function(r) { return r.json(); })
.then(function(projects) {
projectSel.innerHTML = '<option value="">All Projects</option>';
projects.forEach(function(p) {
var opt = document.createElement('option');
opt.value = p.id;
opt.textContent = p.name;
if (p.id === currentProjectId) opt.selected = true;
projectSel.appendChild(opt);
});
document.getElementById('list-filters').submit();
})
.catch(function() {
document.getElementById('list-filters').submit();
});
});
})();
</script>
{% endblock %}

View File

@@ -28,9 +28,21 @@
{% 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=weblinks" class="tab-item {{ 'active' if tab == 'weblinks' }}">Weblinks{% if counts.weblinks %} ({{ counts.weblinks }}){% 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=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 mt-3">
<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>
@@ -38,7 +50,7 @@
<!-- Meeting Notes -->
{% if item.notes_body %}
<div class="card mt-3">
<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>
@@ -46,19 +58,17 @@
<!-- Transcript -->
{% if item.transcript %}
<div class="card mt-3">
<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 mt-3">
<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>
<!-- Quick add action item -->
<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;">
@@ -68,7 +78,6 @@
</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">
@@ -83,16 +92,14 @@
{% 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 %}
{% if not action_items %}
{% else %}
<div style="padding: 16px; color: var(--muted); font-size: 0.85rem;">No action items yet</div>
{% endif %}
{% endfor %}
</div>
<!-- Decisions -->
{% if decisions %}
<div class="card mt-3">
<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">
@@ -104,16 +111,89 @@
</div>
{% endif %}
<!-- Attendees -->
{% if attendees %}
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Attendees<span class="page-count">{{ attendees|length }}</span></h3></div>
{% for att in attendees %}
<div class="list-row">
<span class="row-title"><a href="/contacts/{{ att.id }}">{{ att.first_name }} {{ att.last_name or '' }}</a></span>
{% if att.role %}<span class="row-tag">{{ att.role }}</span>{% endif %}
</div>
{% endfor %}
{% 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 == 'weblinks' %}
<a href="/weblinks/create?meeting_id={{ item.id }}" class="btn btn-ghost btn-sm mb-3">+ New Weblink</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 weblinks 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 == '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 %}

View File

@@ -13,6 +13,8 @@
<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>

View File

@@ -34,8 +34,13 @@
<!-- Tabs -->
<div class="tab-strip">
<a href="/projects/{{ item.id }}?tab=tasks" class="tab-item {{ 'active' if tab == 'tasks' }}">Tasks ({{ tasks|length }})</a>
<a href="/projects/{{ item.id }}?tab=notes" class="tab-item {{ 'active' if tab == 'notes' }}">Notes ({{ notes|length }})</a>
<a href="/projects/{{ item.id }}?tab=links" class="tab-item {{ 'active' if tab == 'links' }}">Links ({{ links|length }})</a>
<a href="/projects/{{ item.id }}?tab=notes" class="tab-item {{ 'active' if tab == 'notes' }}">Notes{% if counts.notes %} ({{ counts.notes }}){% endif %}</a>
<a href="/projects/{{ item.id }}?tab=links" class="tab-item {{ 'active' if tab == 'links' }}">Links{% if counts.links %} ({{ counts.links }}){% endif %}</a>
<a href="/projects/{{ item.id }}?tab=files" class="tab-item {{ 'active' if tab == 'files' }}">Files{% if counts.files %} ({{ counts.files }}){% endif %}</a>
<a href="/projects/{{ item.id }}?tab=lists" class="tab-item {{ 'active' if tab == 'lists' }}">Lists{% if counts.lists %} ({{ counts.lists }}){% endif %}</a>
<a href="/projects/{{ item.id }}?tab=decisions" class="tab-item {{ 'active' if tab == 'decisions' }}">Decisions{% if counts.decisions %} ({{ counts.decisions }}){% endif %}</a>
<a href="/projects/{{ item.id }}?tab=processes" class="tab-item {{ 'active' if tab == 'processes' }}">Processes</a>
<a href="/projects/{{ item.id }}?tab=contacts" class="tab-item {{ 'active' if tab == 'contacts' }}">Contacts{% if counts.contacts %} ({{ counts.contacts }}){% endif %}</a>
</div>
{% if tab == 'tasks' %}
@@ -91,5 +96,75 @@
{% else %}
<div class="empty-state"><div class="empty-state-text">No links yet</div></div>
{% endfor %}
{% elif tab == 'files' %}
<a href="/files/upload?context_type=project&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 project</div></div>
{% endfor %}
{% elif tab == 'lists' %}
<a href="/lists/create?domain_id={{ item.domain_id }}&project_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 project</div></div>
{% endfor %}
{% elif tab == 'decisions' %}
<a href="/decisions/create?project_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>
</div>
{% else %}
<div class="empty-state"><div class="empty-state-text">No decisions linked to this project</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="/projects/{{ 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>
<input type="text" name="role" class="form-input" placeholder="e.g. lead, contributor...">
</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="/projects/{{ 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 project</div></div>
{% endfor %}
{% endif %}
{% endblock %}

View File

@@ -54,6 +54,19 @@
</div>
{% endif %}
<!-- Tabs -->
<div class="tab-strip">
<a href="/tasks/{{ item.id }}?tab=overview" class="tab-item {{ 'active' if tab == 'overview' }}">Overview{% if counts.overview %} ({{ counts.overview }}){% endif %}</a>
<a href="/tasks/{{ item.id }}?tab=notes" class="tab-item {{ 'active' if tab == 'notes' }}">Notes{% if counts.notes %} ({{ counts.notes }}){% endif %}</a>
<a href="/tasks/{{ item.id }}?tab=weblinks" class="tab-item {{ 'active' if tab == 'weblinks' }}">Weblinks{% if counts.weblinks %} ({{ counts.weblinks }}){% endif %}</a>
<a href="/tasks/{{ item.id }}?tab=files" class="tab-item {{ 'active' if tab == 'files' }}">Files{% if counts.files %} ({{ counts.files }}){% endif %}</a>
<a href="/tasks/{{ item.id }}?tab=lists" class="tab-item {{ 'active' if tab == 'lists' }}">Lists{% if counts.lists %} ({{ counts.lists }}){% endif %}</a>
<a href="/tasks/{{ item.id }}?tab=decisions" class="tab-item {{ 'active' if tab == 'decisions' }}">Decisions{% if counts.decisions %} ({{ counts.decisions }}){% endif %}</a>
<a href="/tasks/{{ item.id }}?tab=processes" class="tab-item {{ 'active' if tab == 'processes' }}">Processes</a>
<a href="/tasks/{{ item.id }}?tab=contacts" class="tab-item {{ 'active' if tab == 'contacts' }}">Contacts{% if counts.contacts %} ({{ counts.contacts }}){% endif %}</a>
</div>
{% if tab == 'overview' %}
{% if parent %}
<div class="card mb-4">
<div class="card-title text-sm">Parent Task</div>
@@ -61,7 +74,6 @@
</div>
{% endif %}
<!-- Subtasks -->
<div class="card">
<div class="card-header">
<h2 class="card-title">Subtasks<span class="page-count">{{ subtasks|length }}</span></h2>
@@ -84,6 +96,99 @@
{% endfor %}
</div>
{% elif tab == 'notes' %}
<a href="/notes/create?task_id={{ item.id }}&domain_id={{ item.domain_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 task</div></div>
{% endfor %}
{% elif tab == 'weblinks' %}
<a href="/weblinks/create?task_id={{ item.id }}" class="btn btn-ghost btn-sm mb-3">+ New Weblink</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 weblinks linked to this task</div></div>
{% endfor %}
{% elif tab == 'files' %}
<a href="/files/upload?context_type=task&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 task</div></div>
{% endfor %}
{% elif tab == 'lists' %}
<a href="/lists/create?task_id={{ item.id }}&domain_id={{ item.domain_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 task</div></div>
{% endfor %}
{% elif tab == 'decisions' %}
<a href="/decisions/create?task_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>
</div>
{% else %}
<div class="empty-state"><div class="empty-state-text">No decisions linked to this task</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="/tasks/{{ 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>
<input type="text" name="role" class="form-input" placeholder="e.g. reviewer, assignee...">
</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="/tasks/{{ 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 task</div></div>
{% endfor %}
{% endif %}
<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 %}

View File

@@ -41,9 +41,11 @@
</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 'Add Weblink' }}</button>
<a href="/weblinks" class="btn btn-secondary">Cancel</a>
<a href="{{ '/tasks/' ~ prefill_task_id ~ '?tab=weblinks' if prefill_task_id is defined and prefill_task_id else '/weblinks' }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>

View File

@@ -201,6 +201,24 @@ def all_seeds(sync_conn):
ON CONFLICT (id) DO NOTHING
""", (d["focus"], d["task"]))
# Junction table seeds for contact tabs
cur.execute("""
INSERT INTO contact_tasks (contact_id, task_id, role)
VALUES (%s, %s, 'assignee') ON CONFLICT DO NOTHING
""", (d["contact"], d["task"]))
cur.execute("""
INSERT INTO contact_projects (contact_id, project_id, role)
VALUES (%s, %s, 'stakeholder') ON CONFLICT DO NOTHING
""", (d["contact"], d["project"]))
cur.execute("""
INSERT INTO contact_meetings (contact_id, meeting_id, role)
VALUES (%s, %s, 'attendee') ON CONFLICT DO NOTHING
""", (d["contact"], d["meeting"]))
cur.execute("""
INSERT INTO contact_lists (contact_id, list_id, role)
VALUES (%s, %s, 'contributor') ON CONFLICT DO NOTHING
""", (d["contact"], d["list"]))
# Process
cur.execute("""
INSERT INTO processes (id, name, process_type, status, category, is_deleted, created_at, updated_at)
@@ -253,6 +271,11 @@ def all_seeds(sync_conn):
# Cleanup: delete all seed data (reverse dependency order)
try:
# Junction tables first
cur.execute("DELETE FROM contact_tasks WHERE task_id = %s", (d["task"],))
cur.execute("DELETE FROM contact_projects WHERE project_id = %s", (d["project"],))
cur.execute("DELETE FROM contact_meetings WHERE meeting_id = %s", (d["meeting"],))
cur.execute("DELETE FROM contact_lists WHERE list_id = %s", (d["list"],))
cur.execute("DELETE FROM files WHERE id = %s", (d["file"],))
if os.path.exists(dummy_file_path):
os.remove(dummy_file_path)

View File

@@ -46,6 +46,9 @@ PREFIX_TO_SEED = {
"/time-budgets": "time_budget",
"/files": "file",
"/admin/trash": None,
"/history": None,
"/eisenhower": None,
"/calendar": None,
}
def resolve_path(path_template, seeds):

View File

@@ -1893,3 +1893,743 @@ async def _create_list_item(db: AsyncSession, list_id: str, content: str) -> str
)
await db.commit()
return _id
# ===========================================================================
# Task Detail Tabs
# ===========================================================================
class TestTaskDetailTabs:
"""Test tabbed task detail page - all tabs load, correct content per tab."""
@pytest.mark.asyncio
async def test_overview_tab_default(self, client: AsyncClient, seed_task: dict):
"""Default tab is overview."""
r = await client.get(f"/tasks/{seed_task['id']}")
assert r.status_code == 200
# The active tab has class "tab-item active"
assert 'tab-item active' in r.text
@pytest.mark.asyncio
async def test_all_tabs_return_200(self, client: AsyncClient, seed_task: dict):
"""Every tab on task detail returns 200."""
for tab in ("overview", "notes", "weblinks", "files", "lists", "decisions", "processes", "contacts"):
r = await client.get(f"/tasks/{seed_task['id']}?tab={tab}")
assert r.status_code == 200, f"Tab '{tab}' returned {r.status_code}"
@pytest.mark.asyncio
async def test_contacts_tab_shows_seed_contact(
self, client: AsyncClient, seed_task: dict, seed_contact: dict,
):
"""Contacts tab shows the linked contact from seed data."""
r = await client.get(f"/tasks/{seed_task['id']}?tab=contacts")
assert r.status_code == 200
assert seed_contact["first_name"] in r.text
@pytest.mark.asyncio
async def test_notes_tab_shows_linked_note(
self, client: AsyncClient, db_session: AsyncSession,
seed_task: dict, seed_domain: dict,
):
"""A note linked to a task appears on the notes tab."""
tag = _uid()
note_id = str(uuid.uuid4())
await db_session.execute(
text("INSERT INTO notes (id, title, domain_id, task_id, body, content_format, is_deleted, created_at, updated_at) "
"VALUES (:id, :title, :did, :tid, 'body', 'markdown', false, now(), now())"),
{"id": note_id, "title": f"TaskNote-{tag}", "did": seed_domain["id"], "tid": seed_task["id"]},
)
await db_session.commit()
r = await client.get(f"/tasks/{seed_task['id']}?tab=notes")
assert f"TaskNote-{tag}" in r.text
await db_session.execute(text("DELETE FROM notes WHERE id = :id"), {"id": note_id})
await db_session.commit()
@pytest.mark.asyncio
async def test_invalid_tab_defaults_to_overview(self, client: AsyncClient, seed_task: dict):
"""Invalid tab param still loads the page (defaults to overview)."""
r = await client.get(f"/tasks/{seed_task['id']}?tab=nonexistent")
assert r.status_code == 200
# ===========================================================================
# Task Contact Management
# ===========================================================================
class TestTaskContactManagement:
"""Test adding and removing contacts on task detail."""
@pytest.mark.asyncio
async def test_add_contact_to_task(
self, client: AsyncClient, db_session: AsyncSession,
seed_domain: dict, seed_project: dict, seed_contact: dict,
):
"""Adding a contact to a task creates a junction entry."""
tid = await _create_task(db_session, seed_domain["id"], seed_project["id"], f"ContactTask-{_uid()}")
r = await client.post(f"/tasks/{tid}/contacts/add", data={
"contact_id": seed_contact["id"],
"role": "reviewer",
}, follow_redirects=False)
assert r.status_code == 303
result = await db_session.execute(
text("SELECT role FROM contact_tasks WHERE task_id = :tid AND contact_id = :cid"),
{"tid": tid, "cid": seed_contact["id"]},
)
row = result.first()
assert row is not None, "Contact-task junction not created"
assert row.role == "reviewer"
# Cleanup
await db_session.execute(text("DELETE FROM contact_tasks WHERE task_id = :tid"), {"tid": tid})
await db_session.commit()
await _delete_tasks(db_session, [tid])
@pytest.mark.asyncio
async def test_remove_contact_from_task(
self, client: AsyncClient, db_session: AsyncSession,
seed_domain: dict, seed_project: dict, seed_contact: dict,
):
"""Removing a contact from a task deletes the junction entry."""
tid = await _create_task(db_session, seed_domain["id"], seed_project["id"], f"RmContact-{_uid()}")
await db_session.execute(
text("INSERT INTO contact_tasks (contact_id, task_id, role) VALUES (:cid, :tid, 'test') ON CONFLICT DO NOTHING"),
{"cid": seed_contact["id"], "tid": tid},
)
await db_session.commit()
r = await client.post(f"/tasks/{tid}/contacts/{seed_contact['id']}/remove", follow_redirects=False)
assert r.status_code == 303
result = await db_session.execute(
text("SELECT count(*) FROM contact_tasks WHERE task_id = :tid AND contact_id = :cid"),
{"tid": tid, "cid": seed_contact["id"]},
)
assert result.scalar() == 0
await _delete_tasks(db_session, [tid])
@pytest.mark.asyncio
async def test_add_duplicate_contact_no_error(
self, client: AsyncClient, db_session: AsyncSession,
seed_task: dict, seed_contact: dict,
):
"""Adding same contact twice doesn't crash (ON CONFLICT DO NOTHING)."""
r = await client.post(f"/tasks/{seed_task['id']}/contacts/add", data={
"contact_id": seed_contact["id"],
}, follow_redirects=False)
assert r.status_code == 303
r = await client.post(f"/tasks/{seed_task['id']}/contacts/add", data={
"contact_id": seed_contact["id"],
}, follow_redirects=False)
assert r.status_code == 303
# ===========================================================================
# Project Detail Tabs
# ===========================================================================
class TestProjectDetailTabs:
"""Test tabbed project detail page."""
@pytest.mark.asyncio
async def test_all_project_tabs_return_200(self, client: AsyncClient, seed_project: dict):
"""Every tab on project detail returns 200."""
for tab in ("tasks", "notes", "links", "files", "lists", "decisions", "processes", "contacts"):
r = await client.get(f"/projects/{seed_project['id']}?tab={tab}")
assert r.status_code == 200, f"Project tab '{tab}' returned {r.status_code}"
@pytest.mark.asyncio
async def test_project_contacts_tab_shows_seed_contact(
self, client: AsyncClient, seed_project: dict, seed_contact: dict,
):
"""Contacts tab shows the linked contact."""
r = await client.get(f"/projects/{seed_project['id']}?tab=contacts")
assert seed_contact["first_name"] in r.text
@pytest.mark.asyncio
async def test_project_tasks_tab_shows_seed_task(
self, client: AsyncClient, db_session: AsyncSession,
seed_project: dict, seed_task: dict,
):
"""Tasks tab shows the seed task linked to this project."""
# Ensure seed project and task are not soft-deleted (earlier tests may delete them)
await db_session.execute(
text("UPDATE projects SET is_deleted = false, deleted_at = NULL WHERE id = :id"),
{"id": seed_project["id"]},
)
await db_session.execute(
text("UPDATE tasks SET is_deleted = false, deleted_at = NULL WHERE id = :id"),
{"id": seed_task["id"]},
)
await db_session.commit()
r = await client.get(f"/projects/{seed_project['id']}?tab=tasks")
assert seed_task["title"] in r.text
# ===========================================================================
# Project Contact Management
# ===========================================================================
class TestProjectContactManagement:
"""Test adding and removing contacts on project detail."""
@pytest.mark.asyncio
async def test_add_contact_to_project(
self, client: AsyncClient, db_session: AsyncSession,
seed_project: dict, seed_contact: dict,
):
"""Adding a contact to a project creates a junction entry."""
# First remove existing seed junction to test fresh add
await db_session.execute(
text("DELETE FROM contact_projects WHERE project_id = :pid AND contact_id = :cid"),
{"pid": seed_project["id"], "cid": seed_contact["id"]},
)
await db_session.commit()
r = await client.post(f"/projects/{seed_project['id']}/contacts/add", data={
"contact_id": seed_contact["id"],
"role": "lead",
}, follow_redirects=False)
assert r.status_code == 303
result = await db_session.execute(
text("SELECT role FROM contact_projects WHERE project_id = :pid AND contact_id = :cid"),
{"pid": seed_project["id"], "cid": seed_contact["id"]},
)
row = result.first()
assert row is not None
assert row.role == "lead"
@pytest.mark.asyncio
async def test_remove_contact_from_project(
self, client: AsyncClient, db_session: AsyncSession,
seed_project: dict, seed_contact: dict,
):
"""Removing a contact from a project deletes the junction entry."""
# Ensure junction exists
await db_session.execute(
text("INSERT INTO contact_projects (contact_id, project_id, role) VALUES (:cid, :pid, 'test') ON CONFLICT DO NOTHING"),
{"cid": seed_contact["id"], "pid": seed_project["id"]},
)
await db_session.commit()
r = await client.post(f"/projects/{seed_project['id']}/contacts/{seed_contact['id']}/remove", follow_redirects=False)
assert r.status_code == 303
result = await db_session.execute(
text("SELECT count(*) FROM contact_projects WHERE project_id = :pid AND contact_id = :cid"),
{"pid": seed_project["id"], "cid": seed_contact["id"]},
)
assert result.scalar() == 0
# Re-insert for other tests
await db_session.execute(
text("INSERT INTO contact_projects (contact_id, project_id, role) VALUES (:cid, :pid, 'stakeholder') ON CONFLICT DO NOTHING"),
{"cid": seed_contact["id"], "pid": seed_project["id"]},
)
await db_session.commit()
# ===========================================================================
# Meeting Detail Tabs
# ===========================================================================
class TestMeetingDetailTabs:
"""Test tabbed meeting detail page."""
@pytest.mark.asyncio
async def test_all_meeting_tabs_return_200(self, client: AsyncClient, seed_meeting: dict):
"""Every tab on meeting detail returns 200."""
for tab in ("overview", "notes", "weblinks", "files", "lists", "processes", "contacts"):
r = await client.get(f"/meetings/{seed_meeting['id']}?tab={tab}")
assert r.status_code == 200, f"Meeting tab '{tab}' returned {r.status_code}"
@pytest.mark.asyncio
async def test_meeting_contacts_tab_shows_seed_contact(
self, client: AsyncClient, seed_meeting: dict, seed_contact: dict,
):
"""Contacts tab shows the linked contact."""
r = await client.get(f"/meetings/{seed_meeting['id']}?tab=contacts")
assert seed_contact["first_name"] in r.text
# ===========================================================================
# Meeting Contact Management
# ===========================================================================
class TestMeetingContactManagement:
"""Test adding and removing contacts on meeting detail."""
@pytest.mark.asyncio
async def test_add_contact_to_meeting(
self, client: AsyncClient, db_session: AsyncSession,
seed_meeting: dict, seed_contact: dict,
):
"""Adding a contact to a meeting creates a junction entry."""
await db_session.execute(
text("DELETE FROM contact_meetings WHERE meeting_id = :mid AND contact_id = :cid"),
{"mid": seed_meeting["id"], "cid": seed_contact["id"]},
)
await db_session.commit()
r = await client.post(f"/meetings/{seed_meeting['id']}/contacts/add", data={
"contact_id": seed_contact["id"],
"role": "organizer",
}, follow_redirects=False)
assert r.status_code == 303
result = await db_session.execute(
text("SELECT role FROM contact_meetings WHERE meeting_id = :mid AND contact_id = :cid"),
{"mid": seed_meeting["id"], "cid": seed_contact["id"]},
)
row = result.first()
assert row is not None
assert row.role == "organizer"
@pytest.mark.asyncio
async def test_remove_contact_from_meeting(
self, client: AsyncClient, db_session: AsyncSession,
seed_meeting: dict, seed_contact: dict,
):
"""Removing a contact from a meeting deletes the junction entry."""
await db_session.execute(
text("INSERT INTO contact_meetings (contact_id, meeting_id, role) VALUES (:cid, :mid, 'attendee') ON CONFLICT DO NOTHING"),
{"cid": seed_contact["id"], "mid": seed_meeting["id"]},
)
await db_session.commit()
r = await client.post(f"/meetings/{seed_meeting['id']}/contacts/{seed_contact['id']}/remove", follow_redirects=False)
assert r.status_code == 303
result = await db_session.execute(
text("SELECT count(*) FROM contact_meetings WHERE meeting_id = :mid AND contact_id = :cid"),
{"mid": seed_meeting["id"], "cid": seed_contact["id"]},
)
assert result.scalar() == 0
# Re-insert for other tests
await db_session.execute(
text("INSERT INTO contact_meetings (contact_id, meeting_id, role) VALUES (:cid, :mid, 'attendee') ON CONFLICT DO NOTHING"),
{"cid": seed_contact["id"], "mid": seed_meeting["id"]},
)
await db_session.commit()
# ===========================================================================
# List Contact Management
# ===========================================================================
class TestListContactManagement:
"""Test adding and removing contacts on list detail."""
@pytest.mark.asyncio
async def test_add_contact_to_list(
self, client: AsyncClient, db_session: AsyncSession,
seed_list: dict, seed_contact: dict,
):
"""Adding a contact to a list creates a junction entry."""
await db_session.execute(
text("DELETE FROM contact_lists WHERE list_id = :lid AND contact_id = :cid"),
{"lid": seed_list["id"], "cid": seed_contact["id"]},
)
await db_session.commit()
r = await client.post(f"/lists/{seed_list['id']}/contacts/add", data={
"contact_id": seed_contact["id"],
"role": "owner",
}, follow_redirects=False)
assert r.status_code == 303
result = await db_session.execute(
text("SELECT role FROM contact_lists WHERE list_id = :lid AND contact_id = :cid"),
{"lid": seed_list["id"], "cid": seed_contact["id"]},
)
row = result.first()
assert row is not None
assert row.role == "owner"
@pytest.mark.asyncio
async def test_remove_contact_from_list(
self, client: AsyncClient, db_session: AsyncSession,
seed_list: dict, seed_contact: dict,
):
"""Removing a contact from a list deletes the junction entry."""
await db_session.execute(
text("INSERT INTO contact_lists (contact_id, list_id, role) VALUES (:cid, :lid, 'test') ON CONFLICT DO NOTHING"),
{"cid": seed_contact["id"], "lid": seed_list["id"]},
)
await db_session.commit()
r = await client.post(f"/lists/{seed_list['id']}/contacts/{seed_contact['id']}/remove", follow_redirects=False)
assert r.status_code == 303
result = await db_session.execute(
text("SELECT count(*) FROM contact_lists WHERE list_id = :lid AND contact_id = :cid"),
{"lid": seed_list["id"], "cid": seed_contact["id"]},
)
assert result.scalar() == 0
# Re-insert for other tests
await db_session.execute(
text("INSERT INTO contact_lists (contact_id, list_id, role) VALUES (:cid, :lid, 'contributor') ON CONFLICT DO NOTHING"),
{"cid": seed_contact["id"], "lid": seed_list["id"]},
)
await db_session.commit()
# ===========================================================================
# History Page
# ===========================================================================
class TestHistoryPage:
"""Test the change history page."""
@pytest.mark.asyncio
async def test_history_page_loads(self, client: AsyncClient):
"""History page returns 200."""
r = await client.get("/history/")
assert r.status_code == 200
assert "Change History" in r.text
@pytest.mark.asyncio
async def test_history_shows_seed_entities(
self, client: AsyncClient, db_session: AsyncSession, seed_task: dict,
):
"""History page shows recently modified seed entities."""
# Ensure seed task is not soft-deleted (earlier tests may delete it)
await db_session.execute(
text("UPDATE tasks SET is_deleted = false, deleted_at = NULL WHERE id = :id"),
{"id": seed_task["id"]},
)
await db_session.commit()
r = await client.get("/history/")
assert r.status_code == 200
assert seed_task["title"] in r.text
@pytest.mark.asyncio
async def test_history_entity_type_filter(self, client: AsyncClient):
"""History page can filter by entity type."""
for entity_type in ("tasks", "notes", "projects", "contacts"):
r = await client.get(f"/history/?entity_type={entity_type}")
assert r.status_code == 200
@pytest.mark.asyncio
async def test_history_shows_modified_vs_created(
self, client: AsyncClient, db_session: AsyncSession, seed_domain: dict,
):
"""Items with updated_at different from created_at show as 'modified'."""
tag = _uid()
note_id = str(uuid.uuid4())
await db_session.execute(
text("INSERT INTO notes (id, title, domain_id, body, content_format, is_deleted, created_at, updated_at) "
"VALUES (:id, :title, :did, 'body', 'markdown', false, now() - interval '1 hour', now())"),
{"id": note_id, "title": f"HistMod-{tag}", "did": seed_domain["id"]},
)
await db_session.commit()
r = await client.get("/history/?entity_type=notes")
assert f"HistMod-{tag}" in r.text
assert "modified" in r.text
await db_session.execute(text("DELETE FROM notes WHERE id = :id"), {"id": note_id})
await db_session.commit()
# ===========================================================================
# Search Prefix Matching
# ===========================================================================
class TestSearchPrefixMatching:
"""Test the improved search with prefix tsquery and ILIKE fallback."""
@pytest.mark.asyncio
async def test_search_api_short_query_rejected(self, client: AsyncClient):
"""Query shorter than 2 chars returns empty results."""
r = await client.get("/search/api?q=a")
assert r.status_code == 200
data = r.json()
assert len(data["results"]) == 0
@pytest.mark.asyncio
async def test_search_api_result_structure(self, client: AsyncClient):
"""Search results have expected fields."""
r = await client.get("/search/api?q=Test")
assert r.status_code == 200
data = r.json()
if data["results"]:
result = data["results"][0]
assert "type" in result
assert "id" in result
assert "name" in result
assert "url" in result
assert "icon" in result
assert "rank" in result
@pytest.mark.asyncio
async def test_search_page_returns_200(self, client: AsyncClient):
"""Full search page works."""
r = await client.get("/search/?q=Test")
assert r.status_code == 200
@pytest.mark.asyncio
async def test_search_ilike_fallback(
self, client: AsyncClient, db_session: AsyncSession, seed_domain: dict,
):
"""ILIKE fallback finds items that tsvector might miss."""
tag = _uid()
# Create a note with a very specific title
note_id = str(uuid.uuid4())
await db_session.execute(
text("INSERT INTO notes (id, title, domain_id, body, content_format, is_deleted, created_at, updated_at) "
"VALUES (:id, :title, :did, 'body', 'markdown', false, now(), now())"),
{"id": note_id, "title": f"XqzNote{tag}", "did": seed_domain["id"]},
)
await db_session.commit()
r = await client.get(f"/search/api?q=XqzNote{tag}&entity_type=notes")
data = r.json()
ids = [res["id"] for res in data["results"]]
assert note_id in ids, "ILIKE fallback should find exact substring match"
await db_session.execute(text("DELETE FROM notes WHERE id = :id"), {"id": note_id})
await db_session.commit()
# ===========================================================================
# Projects API (Dynamic Filtering)
# ===========================================================================
class TestProjectsAPI:
"""Test the /projects/api/by-domain JSON endpoint."""
@pytest.mark.asyncio
async def test_projects_by_domain_returns_json(
self, client: AsyncClient, db_session: AsyncSession, seed_domain: dict,
):
"""API returns JSON list of projects for a domain."""
# Ensure seed project is not soft-deleted (earlier tests may delete it)
await db_session.execute(
text("UPDATE projects SET is_deleted = false, deleted_at = NULL WHERE id = :id AND is_deleted = true"),
{"id": "a0000000-0000-0000-0000-000000000003"},
)
await db_session.commit()
r = await client.get(f"/projects/api/by-domain?domain_id={seed_domain['id']}")
assert r.status_code == 200
data = r.json()
assert isinstance(data, list)
assert any(p["name"] == "Test Project" for p in data)
@pytest.mark.asyncio
async def test_projects_by_domain_empty(self, client: AsyncClient):
"""API returns empty list for nonexistent domain."""
fake_id = str(uuid.uuid4())
r = await client.get(f"/projects/api/by-domain?domain_id={fake_id}")
assert r.status_code == 200
data = r.json()
assert data == []
@pytest.mark.asyncio
async def test_projects_by_domain_no_param(self, client: AsyncClient):
"""API without domain_id returns all active projects."""
r = await client.get("/projects/api/by-domain")
assert r.status_code == 200
data = r.json()
assert isinstance(data, list)
# ===========================================================================
# Eisenhower Matrix Filters
# ===========================================================================
class TestEisenhowerFilters:
"""Test Eisenhower matrix page with filter parameters."""
@pytest.mark.asyncio
async def test_eisenhower_page_loads(self, client: AsyncClient):
"""Eisenhower page returns 200."""
r = await client.get("/eisenhower/")
assert r.status_code == 200
@pytest.mark.asyncio
async def test_eisenhower_filter_by_domain(
self, client: AsyncClient, seed_domain: dict,
):
"""Eisenhower page accepts domain_id filter."""
r = await client.get(f"/eisenhower/?domain_id={seed_domain['id']}")
assert r.status_code == 200
@pytest.mark.asyncio
async def test_eisenhower_filter_by_status(self, client: AsyncClient):
"""Eisenhower page accepts status filter."""
r = await client.get("/eisenhower/?status=open")
assert r.status_code == 200
@pytest.mark.asyncio
async def test_eisenhower_combined_filters(
self, client: AsyncClient, seed_domain: dict, seed_project: dict,
):
"""Eisenhower page accepts multiple filters."""
r = await client.get(
f"/eisenhower/?domain_id={seed_domain['id']}&project_id={seed_project['id']}&status=open"
)
assert r.status_code == 200
# ===========================================================================
# Focus Filters
# ===========================================================================
class TestFocusFilters:
"""Test focus page filter parameters."""
@pytest.mark.asyncio
async def test_focus_filter_by_domain(
self, client: AsyncClient, seed_domain: dict,
):
"""Focus page accepts domain_id filter for available tasks."""
r = await client.get(f"/focus/?domain_id={seed_domain['id']}")
assert r.status_code == 200
@pytest.mark.asyncio
async def test_focus_filter_by_project(
self, client: AsyncClient, seed_project: dict,
):
"""Focus page accepts project_id filter."""
r = await client.get(f"/focus/?project_id={seed_project['id']}")
assert r.status_code == 200
@pytest.mark.asyncio
async def test_focus_combined_filters(
self, client: AsyncClient, seed_domain: dict, seed_project: dict,
):
"""Focus page accepts multiple filters."""
r = await client.get(
f"/focus/?domain_id={seed_domain['id']}&project_id={seed_project['id']}"
)
assert r.status_code == 200
# ===========================================================================
# Entity Create with task_id/meeting_id
# ===========================================================================
class TestEntityCreateWithParentContext:
"""Test creating entities with task_id or meeting_id context."""
@pytest.mark.asyncio
async def test_create_note_with_task_id(
self, client: AsyncClient, db_session: AsyncSession,
seed_task: dict, seed_domain: dict,
):
"""Creating a note with task_id sets the FK and redirects to task tab."""
tag = _uid()
r = await client.post("/notes/create", data={
"title": f"TaskNote-{tag}",
"domain_id": seed_domain["id"],
"body": "Test body",
"content_format": "markdown",
"task_id": seed_task["id"],
}, follow_redirects=False)
assert r.status_code == 303
# Should redirect back to task's notes tab
location = r.headers.get("location", "")
assert "tab=notes" in location
result = await db_session.execute(
text("SELECT task_id FROM notes WHERE title = :t AND is_deleted = false"),
{"t": f"TaskNote-{tag}"},
)
row = result.first()
assert row is not None
assert str(row.task_id) == seed_task["id"]
# Cleanup
await db_session.execute(
text("DELETE FROM notes WHERE title = :t"), {"t": f"TaskNote-{tag}"}
)
await db_session.commit()
@pytest.mark.asyncio
async def test_create_note_form_with_task_prefill(
self, client: AsyncClient, seed_task: dict,
):
"""Note create form with task_id query param loads correctly."""
r = await client.get(f"/notes/create?task_id={seed_task['id']}")
assert r.status_code == 200
assert seed_task["id"] in r.text
@pytest.mark.asyncio
async def test_create_weblink_with_task_id(
self, client: AsyncClient, db_session: AsyncSession, seed_task: dict,
):
"""Creating a weblink with task_id sets the FK."""
tag = _uid()
r = await client.post("/weblinks/create", data={
"label": f"TaskWeblink-{tag}",
"url": "https://example.com/test",
"task_id": seed_task["id"],
}, follow_redirects=False)
assert r.status_code == 303
result = await db_session.execute(
text("SELECT task_id FROM weblinks WHERE label = :l AND is_deleted = false"),
{"l": f"TaskWeblink-{tag}"},
)
row = result.first()
assert row is not None
assert str(row.task_id) == seed_task["id"]
await db_session.execute(
text("DELETE FROM weblinks WHERE label = :l"), {"l": f"TaskWeblink-{tag}"}
)
await db_session.commit()
@pytest.mark.asyncio
async def test_create_list_with_task_id(
self, client: AsyncClient, db_session: AsyncSession,
seed_task: dict, seed_domain: dict,
):
"""Creating a list with task_id sets the FK."""
tag = _uid()
r = await client.post("/lists/create", data={
"name": f"TaskList-{tag}",
"domain_id": seed_domain["id"],
"list_type": "checklist",
"task_id": seed_task["id"],
}, follow_redirects=False)
assert r.status_code == 303
result = await db_session.execute(
text("SELECT task_id FROM lists WHERE name = :n AND is_deleted = false"),
{"n": f"TaskList-{tag}"},
)
row = result.first()
assert row is not None
assert str(row.task_id) == seed_task["id"]
await db_session.execute(
text("DELETE FROM lists WHERE name = :n"), {"n": f"TaskList-{tag}"}
)
await db_session.commit()
@pytest.mark.asyncio
async def test_create_decision_with_task_id(
self, client: AsyncClient, db_session: AsyncSession, seed_task: dict,
):
"""Creating a decision with task_id sets the FK."""
tag = _uid()
r = await client.post("/decisions/create", data={
"title": f"TaskDecision-{tag}",
"status": "proposed",
"impact": "medium",
"task_id": seed_task["id"],
}, follow_redirects=False)
assert r.status_code == 303
result = await db_session.execute(
text("SELECT task_id FROM decisions WHERE title = :t AND is_deleted = false"),
{"t": f"TaskDecision-{tag}"},
)
row = result.first()
assert row is not None
assert str(row.task_id) == seed_task["id"]
await db_session.execute(
text("DELETE FROM decisions WHERE title = :t"), {"t": f"TaskDecision-{tag}"}
)
await db_session.commit()