Compare commits

...

10 Commits

Author SHA1 Message Date
ff9be1249a File Sync and repoint to WebDAV folder 2026-03-02 23:58:23 +00:00
c8a1d5ba40 feat: bookmark folder reordering and add-existing-link
Add up/down arrow buttons to reorder links within a folder, with lazy
sort_order initialization. Add dropdown to move existing links into the
current folder.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 21:08:20 +00:00
0ed86ee2dc Links and Other Enhancements 2026-03-02 19:55:04 +00:00
cf84d6d2dd various enhancements for new tabs and bug fixes 2026-03-02 17:35:00 +00:00
9dedf6dbf2 various bug fixes Sunday 20260301 5:15pm 2026-03-01 23:44:47 +00:00
bcb0007217 feat: topbar quick capture, fix mobile bottom nav and more panel
- Add quick capture input to topbar (desktop: inline 250px, mobile: full-width
  with submit button) that POSTs to /capture/add with redirect back to current page
- Fix mobile bottom bar: 5 items (Dashboard, Focus, Tasks, Capture, More),
  nowrap/overflow:hidden to prevent multi-line wrapping
- Rebuild mobile More panel as 3-column grid overlay (z-index 998/999/1000 stack)
  with backdrop dismiss, 8 items: Calendar, Notes, Meetings, Decisions, Contacts,
  Processes, Weblinks, Admin
- Add file seed fixture and registry mapping to eliminate 4 test skips
- Add time_budgets form factory patterns to fix 2 test failures

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 22:44:41 +00:00
8499c99721 feat: eisenhower matrix view 2026-03-01 22:22:19 +00:00
d792f89fe6 feat: unified calendar view and eisenhower matrix view 2026-03-01 22:17:23 +00:00
c21cbf5e9b fix: mobile bottom nav bar sizing and layout and capture queue input visible on mobile 2026-03-01 22:10:00 +00:00
21bbb169f9 feat: processes and process runs CRUD 2026-03-01 22:04:24 +00:00
61 changed files with 9674 additions and 636 deletions

View File

@@ -158,6 +158,10 @@ class BaseRepository:
"rationale", "decided_at", "superseded_by_id", "rationale", "decided_at", "superseded_by_id",
"start_at", "end_at", "location", "agenda", "transcript", "notes_body", "start_at", "end_at", "location", "agenda", "transcript", "notes_body",
"priority", "recurrence", "mime_type", "priority", "recurrence", "mime_type",
"category", "instructions", "expected_output", "estimated_days",
"contact_id", "started_at",
"weekly_hours", "effective_from",
"task_id", "meeting_id",
} }
clean_data = {} clean_data = {}
for k, v in data.items(): for k, v in data.items():

View File

@@ -9,13 +9,13 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
DATABASE_URL: postgresql+asyncpg://postgres:${DB_PASSWORD}@lifeos-db:5432/lifeos_prod DATABASE_URL: postgresql+asyncpg://postgres:${DB_PASSWORD}@lifeos-db:5432/lifeos_prod
FILE_STORAGE_PATH: /opt/lifeos/files/prod FILE_STORAGE_PATH: /opt/lifeos/webdav
ENVIRONMENT: production ENVIRONMENT: production
command: uvicorn main:app --host 0.0.0.0 --port 8002 --workers 1 command: uvicorn main:app --host 0.0.0.0 --port 8002 --workers 1
ports: ports:
- "8002:8002" - "8002:8002"
volumes: volumes:
- /opt/lifeos/prod/files:/opt/lifeos/files/prod - /opt/lifeos/webdav:/opt/lifeos/webdav
networks: networks:
- lifeos_network - lifeos_network
depends_on: depends_on:
@@ -29,13 +29,13 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
DATABASE_URL: postgresql+asyncpg://postgres:${DB_PASSWORD}@lifeos-db:5432/lifeos_dev DATABASE_URL: postgresql+asyncpg://postgres:${DB_PASSWORD}@lifeos-db:5432/lifeos_dev
FILE_STORAGE_PATH: /opt/lifeos/files/dev FILE_STORAGE_PATH: /opt/lifeos/webdav
ENVIRONMENT: development ENVIRONMENT: development
command: uvicorn main:app --host 0.0.0.0 --port 8003 --workers 1 --reload command: uvicorn main:app --host 0.0.0.0 --port 8003 --workers 1 --reload
ports: ports:
- "8003:8003" - "8003:8003"
volumes: volumes:
- /opt/lifeos/dev/files:/opt/lifeos/files/dev - /opt/lifeos/webdav:/opt/lifeos/webdav
- .:/app # hot reload in dev - .:/app # hot reload in dev
networks: networks:
- lifeos_network - lifeos_network

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.

46
main.py
View File

@@ -39,6 +39,11 @@ from routers import (
weblinks as weblinks_router, weblinks as weblinks_router,
appointments as appointments_router, appointments as appointments_router,
time_tracking as time_tracking_router, time_tracking as time_tracking_router,
processes as processes_router,
calendar as calendar_router,
time_budgets as time_budgets_router,
eisenhower as eisenhower_router,
history as history_router,
) )
@@ -150,12 +155,48 @@ async def dashboard(request: Request):
""")) """))
stats = dict(result.first()._mapping) stats = dict(result.first()._mapping)
# Overdue projects (target_date in the past)
result = await db.execute(text("""
SELECT p.id, p.name, p.priority, p.target_date, p.status,
d.name as domain_name, d.color as domain_color,
count(t.id) FILTER (WHERE t.is_deleted = false) as task_count,
count(t.id) FILTER (WHERE t.is_deleted = false AND t.status = 'done') as done_count
FROM projects p
LEFT JOIN domains d ON p.domain_id = d.id
LEFT JOIN tasks t ON t.project_id = p.id
WHERE p.is_deleted = false AND p.status IN ('active', 'on_hold')
AND p.target_date < CURRENT_DATE
GROUP BY p.id, d.name, d.color
ORDER BY p.target_date ASC
LIMIT 10
"""))
overdue_projects = [dict(r._mapping) for r in result]
# Upcoming project deadlines (next 30 days)
result = await db.execute(text("""
SELECT p.id, p.name, p.priority, p.target_date, p.status,
d.name as domain_name, d.color as domain_color,
count(t.id) FILTER (WHERE t.is_deleted = false) as task_count,
count(t.id) FILTER (WHERE t.is_deleted = false AND t.status = 'done') as done_count
FROM projects p
LEFT JOIN domains d ON p.domain_id = d.id
LEFT JOIN tasks t ON t.project_id = p.id
WHERE p.is_deleted = false AND p.status IN ('active', 'on_hold')
AND p.target_date >= CURRENT_DATE AND p.target_date <= CURRENT_DATE + INTERVAL '30 days'
GROUP BY p.id, d.name, d.color
ORDER BY p.target_date ASC
LIMIT 10
"""))
upcoming_projects = [dict(r._mapping) for r in result]
return templates.TemplateResponse("dashboard.html", { return templates.TemplateResponse("dashboard.html", {
"request": request, "request": request,
"sidebar": sidebar, "sidebar": sidebar,
"focus_items": focus_items, "focus_items": focus_items,
"overdue_tasks": overdue_tasks, "overdue_tasks": overdue_tasks,
"upcoming_tasks": upcoming_tasks, "upcoming_tasks": upcoming_tasks,
"overdue_projects": overdue_projects,
"upcoming_projects": upcoming_projects,
"stats": stats, "stats": stats,
"page_title": "Dashboard", "page_title": "Dashboard",
"active_nav": "dashboard", "active_nav": "dashboard",
@@ -193,3 +234,8 @@ app.include_router(decisions_router.router)
app.include_router(weblinks_router.router) app.include_router(weblinks_router.router)
app.include_router(appointments_router.router) app.include_router(appointments_router.router)
app.include_router(time_tracking_router.router) app.include_router(time_tracking_router.router)
app.include_router(processes_router.router)
app.include_router(calendar_router.router)
app.include_router(time_budgets_router.router)
app.include_router(eisenhower_router.router)
app.include_router(history_router.router)

File diff suppressed because it is too large Load Diff

View File

@@ -27,11 +27,13 @@ TRASH_ENTITIES = [
{"table": "meetings", "label": "Meetings", "name_col": "title", "url": "/meetings/{id}"}, {"table": "meetings", "label": "Meetings", "name_col": "title", "url": "/meetings/{id}"},
{"table": "decisions", "label": "Decisions", "name_col": "title", "url": "/decisions/{id}"}, {"table": "decisions", "label": "Decisions", "name_col": "title", "url": "/decisions/{id}"},
{"table": "files", "label": "Files", "name_col": "original_filename", "url": "/files"}, {"table": "files", "label": "Files", "name_col": "original_filename", "url": "/files"},
{"table": "weblinks", "label": "Weblinks", "name_col": "label", "url": "/weblinks"}, {"table": "link_folders", "label": "Link Folders", "name_col": "name", "url": "/weblinks"},
{"table": "weblink_folders", "label": "Weblink Folders", "name_col": "name", "url": "/weblinks"},
{"table": "appointments", "label": "Appointments", "name_col": "title", "url": "/appointments/{id}"}, {"table": "appointments", "label": "Appointments", "name_col": "title", "url": "/appointments/{id}"},
{"table": "capture", "label": "Capture", "name_col": "raw_text", "url": "/capture"}, {"table": "capture", "label": "Capture", "name_col": "raw_text", "url": "/capture"},
{"table": "daily_focus", "label": "Focus Items", "name_col": "id", "url": "/focus"}, {"table": "daily_focus", "label": "Focus Items", "name_col": "id", "url": "/focus"},
{"table": "processes", "label": "Processes", "name_col": "name", "url": "/processes/{id}"},
{"table": "process_runs", "label": "Process Runs", "name_col": "title", "url": "/processes/runs/{id}"},
{"table": "time_budgets", "label": "Time Budgets", "name_col": "id", "url": "/time-budgets"},
] ]

138
routers/calendar.py Normal file
View File

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

View File

@@ -13,6 +13,7 @@ from sqlalchemy import text
from core.database import get_db from core.database import get_db
from core.base_repository import BaseRepository from core.base_repository import BaseRepository
from core.sidebar import get_sidebar_data from core.sidebar import get_sidebar_data
from routers.weblinks import get_default_folder_id
router = APIRouter(prefix="/capture", tags=["capture"]) router = APIRouter(prefix="/capture", tags=["capture"])
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
@@ -24,7 +25,7 @@ CONVERT_TYPES = {
"list_item": "List Item", "list_item": "List Item",
"contact": "Contact", "contact": "Contact",
"decision": "Decision", "decision": "Decision",
"weblink": "Weblink", "link": "Link",
} }
@@ -77,6 +78,7 @@ async def list_capture(request: Request, show: str = "inbox", db: AsyncSession =
async def add_capture( async def add_capture(
request: Request, request: Request,
raw_text: str = Form(...), raw_text: str = Form(...),
redirect_to: Optional[str] = Form(None),
area_id: Optional[str] = Form(None), area_id: Optional[str] = Form(None),
project_id: Optional[str] = Form(None), project_id: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -95,7 +97,8 @@ async def add_capture(
data["project_id"] = project_id data["project_id"] = project_id
await repo.create(data) await repo.create(data)
return RedirectResponse(url="/capture", status_code=303) url = redirect_to if redirect_to and redirect_to.startswith("/") else "/capture"
return RedirectResponse(url=url, status_code=303)
# ---- Batch undo (must be before /{capture_id} routes) ---- # ---- Batch undo (must be before /{capture_id} routes) ----
@@ -307,8 +310,8 @@ async def convert_to_decision(
return RedirectResponse(url=f"/decisions/{decision['id']}", status_code=303) return RedirectResponse(url=f"/decisions/{decision['id']}", status_code=303)
@router.post("/{capture_id}/to-weblink") @router.post("/{capture_id}/to-link")
async def convert_to_weblink( async def convert_to_link(
capture_id: str, request: Request, capture_id: str, request: Request,
label: Optional[str] = Form(None), label: Optional[str] = Form(None),
url: Optional[str] = Form(None), url: Optional[str] = Form(None),
@@ -319,7 +322,7 @@ async def convert_to_weblink(
if not item: if not item:
return RedirectResponse(url="/capture", status_code=303) return RedirectResponse(url="/capture", status_code=303)
weblink_repo = BaseRepository("weblinks", db) link_repo = BaseRepository("links", db)
raw = item["raw_text"] raw = item["raw_text"]
url_match = re.search(r'https?://\S+', raw) url_match = re.search(r'https?://\S+', raw)
link_url = (url.strip() if url and url.strip() else None) or (url_match.group(0) if url_match else raw) link_url = (url.strip() if url and url.strip() else None) or (url_match.group(0) if url_match else raw)
@@ -328,13 +331,20 @@ async def convert_to_weblink(
link_label = link_url link_label = link_url
data = {"label": link_label, "url": link_url} data = {"label": link_label, "url": link_url}
weblink = await weblink_repo.create(data) link = await link_repo.create(data)
# Assign to Default folder
default_fid = await get_default_folder_id(db)
await db.execute(text("""
INSERT INTO folder_links (folder_id, link_id)
VALUES (:fid, :lid) ON CONFLICT DO NOTHING
"""), {"fid": default_fid, "lid": link["id"]})
await capture_repo.update(capture_id, { await capture_repo.update(capture_id, {
"processed": True, "converted_to_type": "weblink", "processed": True, "converted_to_type": "link",
"converted_to_id": str(weblink["id"]), "converted_to_id": str(link["id"]),
}) })
return RedirectResponse(url="/weblinks", status_code=303) return RedirectResponse(url="/links", status_code=303)
@router.post("/{capture_id}/dismiss") @router.post("/{capture_id}/dismiss")

View File

@@ -56,6 +56,7 @@ async def list_decisions(
async def create_form( async def create_form(
request: Request, request: Request,
meeting_id: Optional[str] = None, meeting_id: Optional[str] = None,
task_id: Optional[str] = None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
sidebar = await get_sidebar_data(db) sidebar = await get_sidebar_data(db)
@@ -72,6 +73,7 @@ async def create_form(
"page_title": "New Decision", "active_nav": "decisions", "page_title": "New Decision", "active_nav": "decisions",
"item": None, "item": None,
"prefill_meeting_id": meeting_id or "", "prefill_meeting_id": meeting_id or "",
"prefill_task_id": task_id or "",
}) })
@@ -84,6 +86,7 @@ async def create_decision(
impact: str = Form("medium"), impact: str = Form("medium"),
decided_at: Optional[str] = Form(None), decided_at: Optional[str] = Form(None),
meeting_id: Optional[str] = Form(None), meeting_id: Optional[str] = Form(None),
task_id: Optional[str] = Form(None),
tags: Optional[str] = Form(None), tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
@@ -96,10 +99,14 @@ async def create_decision(
data["decided_at"] = decided_at data["decided_at"] = decided_at
if meeting_id and meeting_id.strip(): if meeting_id and meeting_id.strip():
data["meeting_id"] = meeting_id data["meeting_id"] = meeting_id
if task_id and task_id.strip():
data["task_id"] = task_id
if tags and tags.strip(): if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()] data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
decision = await repo.create(data) 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) return RedirectResponse(url=f"/decisions/{decision['id']}", status_code=303)

120
routers/eisenhower.py Normal file
View File

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

View File

@@ -1,12 +1,11 @@
"""Files: upload, download, list, preview, and polymorphic entity attachment.""" """Files: upload, download, list, preview, folder-aware storage, and WebDAV sync."""
import os import os
import uuid import mimetypes
import shutil
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Request, Form, Depends, UploadFile, File as FastAPIFile from fastapi import APIRouter, Request, Form, Depends, UploadFile, File as FastAPIFile
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi.responses import RedirectResponse, FileResponse from fastapi.responses import RedirectResponse, FileResponse, HTMLResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text from sqlalchemy import text
from typing import Optional from typing import Optional
@@ -18,7 +17,7 @@ from core.sidebar import get_sidebar_data
router = APIRouter(prefix="/files", tags=["files"]) router = APIRouter(prefix="/files", tags=["files"])
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
FILE_STORAGE_PATH = os.getenv("FILE_STORAGE_PATH", "/opt/lifeos/files/dev") FILE_STORAGE_PATH = os.getenv("FILE_STORAGE_PATH", "/opt/lifeos/webdav")
# Ensure storage dir exists # Ensure storage dir exists
Path(FILE_STORAGE_PATH).mkdir(parents=True, exist_ok=True) Path(FILE_STORAGE_PATH).mkdir(parents=True, exist_ok=True)
@@ -29,16 +28,120 @@ PREVIEWABLE = {
"application/pdf", "text/plain", "text/html", "text/csv", "application/pdf", "text/plain", "text/html", "text/csv",
} }
# Files to skip during sync
SKIP_FILES = {".DS_Store", "Thumbs.db", ".gitkeep", "desktop.ini"}
def _resolve_path(item):
"""Resolve a DB record's relative storage_path to an absolute path."""
return os.path.join(FILE_STORAGE_PATH, item["storage_path"])
def get_folders():
"""Walk FILE_STORAGE_PATH and return sorted list of relative folder paths."""
folders = []
for dirpath, dirnames, _filenames in os.walk(FILE_STORAGE_PATH):
# Skip hidden directories
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
rel = os.path.relpath(dirpath, FILE_STORAGE_PATH)
if rel != ".":
folders.append(rel)
return sorted(folders)
def resolve_collision(folder_abs, filename):
"""If filename exists in folder_abs, return name (2).ext, name (3).ext, etc."""
target = os.path.join(folder_abs, filename)
if not os.path.exists(target):
return filename
name, ext = os.path.splitext(filename)
counter = 2
while True:
candidate = f"{name} ({counter}){ext}"
if not os.path.exists(os.path.join(folder_abs, candidate)):
return candidate
counter += 1
async def sync_files(db: AsyncSession):
"""Sync filesystem state with the database.
- Files on disk not in DB → create record
- Active DB records with missing files → soft-delete
Returns dict with added/removed counts.
"""
added = 0
removed = 0
# Build set of all relative file paths on disk
disk_files = set()
for dirpath, dirnames, filenames in os.walk(FILE_STORAGE_PATH):
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
for fname in filenames:
if fname in SKIP_FILES or fname.startswith("."):
continue
abs_path = os.path.join(dirpath, fname)
rel_path = os.path.relpath(abs_path, FILE_STORAGE_PATH)
disk_files.add(rel_path)
# Get ALL DB records (including soft-deleted) to avoid re-creating deleted files
result = await db.execute(text(
"SELECT id, storage_path, is_deleted FROM files"
))
db_records = [dict(r._mapping) for r in result]
# Build lookup sets
all_db_paths = {r["storage_path"] for r in db_records}
active_db_paths = {r["storage_path"] for r in db_records if not r["is_deleted"]}
# New on disk, not in DB at all → create record
new_files = disk_files - all_db_paths
for rel_path in new_files:
abs_path = os.path.join(FILE_STORAGE_PATH, rel_path)
filename = os.path.basename(rel_path)
mime_type = mimetypes.guess_type(filename)[0]
try:
size_bytes = os.path.getsize(abs_path)
except OSError:
continue
repo = BaseRepository("files", db)
await repo.create({
"filename": filename,
"original_filename": filename,
"storage_path": rel_path,
"mime_type": mime_type,
"size_bytes": size_bytes,
})
added += 1
# Active in DB but missing from disk → soft-delete
missing_files = active_db_paths - disk_files
for record in db_records:
if record["storage_path"] in missing_files and not record["is_deleted"]:
repo = BaseRepository("files", db)
await repo.soft_delete(record["id"])
removed += 1
return {"added": added, "removed": removed}
@router.get("/") @router.get("/")
async def list_files( async def list_files(
request: Request, request: Request,
folder: Optional[str] = None,
context_type: Optional[str] = None, context_type: Optional[str] = None,
context_id: Optional[str] = None, context_id: Optional[str] = None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
sidebar = await get_sidebar_data(db) sidebar = await get_sidebar_data(db)
# Auto-sync on page load
sync_result = await sync_files(db)
folders = get_folders()
if context_type and context_id: if context_type and context_id:
# Files attached to a specific entity # Files attached to a specific entity
result = await db.execute(text(""" result = await db.execute(text("""
@@ -48,19 +151,40 @@ async def list_files(
WHERE f.is_deleted = false AND fm.context_type = :ct AND fm.context_id = :cid WHERE f.is_deleted = false AND fm.context_type = :ct AND fm.context_id = :cid
ORDER BY f.created_at DESC ORDER BY f.created_at DESC
"""), {"ct": context_type, "cid": context_id}) """), {"ct": context_type, "cid": context_id})
elif folder is not None:
if folder == "":
# Root folder: files with no directory separator in storage_path
result = await db.execute(text("""
SELECT * FROM files
WHERE is_deleted = false AND storage_path NOT LIKE '%/%'
ORDER BY created_at DESC
"""))
else:
# Specific folder: storage_path starts with folder/
result = await db.execute(text("""
SELECT * FROM files
WHERE is_deleted = false AND storage_path LIKE :prefix
ORDER BY created_at DESC
"""), {"prefix": folder + "/%"})
else: else:
# All files # All files
result = await db.execute(text(""" result = await db.execute(text("""
SELECT f.* FROM files f SELECT * FROM files
WHERE f.is_deleted = false WHERE is_deleted = false
ORDER BY f.created_at DESC ORDER BY created_at DESC
LIMIT 100
""")) """))
items = [dict(r._mapping) for r in result] items = [dict(r._mapping) for r in result]
# Add derived folder field for display
for item in items:
dirname = os.path.dirname(item["storage_path"])
item["folder"] = dirname if dirname else "/"
return templates.TemplateResponse("files.html", { return templates.TemplateResponse("files.html", {
"request": request, "sidebar": sidebar, "items": items, "request": request, "sidebar": sidebar, "items": items,
"folders": folders, "current_folder": folder,
"sync_result": sync_result,
"context_type": context_type or "", "context_type": context_type or "",
"context_id": context_id or "", "context_id": context_id or "",
"page_title": "Files", "active_nav": "files", "page_title": "Files", "active_nav": "files",
@@ -70,13 +194,16 @@ async def list_files(
@router.get("/upload") @router.get("/upload")
async def upload_form( async def upload_form(
request: Request, request: Request,
folder: Optional[str] = None,
context_type: Optional[str] = None, context_type: Optional[str] = None,
context_id: Optional[str] = None, context_id: Optional[str] = None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
sidebar = await get_sidebar_data(db) sidebar = await get_sidebar_data(db)
folders = get_folders()
return templates.TemplateResponse("file_upload.html", { return templates.TemplateResponse("file_upload.html", {
"request": request, "sidebar": sidebar, "request": request, "sidebar": sidebar,
"folders": folders, "prefill_folder": folder or "",
"context_type": context_type or "", "context_type": context_type or "",
"context_id": context_id or "", "context_id": context_id or "",
"page_title": "Upload File", "active_nav": "files", "page_title": "Upload File", "active_nav": "files",
@@ -89,19 +216,41 @@ async def upload_file(
file: UploadFile = FastAPIFile(...), file: UploadFile = FastAPIFile(...),
description: Optional[str] = Form(None), description: Optional[str] = Form(None),
tags: Optional[str] = Form(None), tags: Optional[str] = Form(None),
folder: Optional[str] = Form(None),
new_folder: Optional[str] = Form(None),
context_type: Optional[str] = Form(None), context_type: Optional[str] = Form(None),
context_id: Optional[str] = Form(None), context_id: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
# Generate storage filename # Determine target folder
file_uuid = str(uuid.uuid4()) target_folder = ""
if new_folder and new_folder.strip():
target_folder = new_folder.strip().strip("/")
elif folder and folder.strip():
target_folder = folder.strip()
# Build absolute folder path and ensure it exists
if target_folder:
folder_abs = os.path.join(FILE_STORAGE_PATH, target_folder)
else:
folder_abs = FILE_STORAGE_PATH
os.makedirs(folder_abs, exist_ok=True)
# Use original filename, handle collisions
original = file.filename or "unknown" original = file.filename or "unknown"
safe_name = original.replace("/", "_").replace("\\", "_") safe_name = original.replace("/", "_").replace("\\", "_")
storage_name = f"{file_uuid}_{safe_name}" final_name = resolve_collision(folder_abs, safe_name)
storage_path = os.path.join(FILE_STORAGE_PATH, storage_name)
# Build relative storage path
if target_folder:
storage_path = os.path.join(target_folder, final_name)
else:
storage_path = final_name
abs_path = os.path.join(FILE_STORAGE_PATH, storage_path)
# Save to disk # Save to disk
with open(storage_path, "wb") as f: with open(abs_path, "wb") as f:
content = await file.read() content = await file.read()
f.write(content) f.write(content)
@@ -110,7 +259,7 @@ async def upload_file(
# Insert file record # Insert file record
repo = BaseRepository("files", db) repo = BaseRepository("files", db)
data = { data = {
"filename": storage_name, "filename": final_name,
"original_filename": original, "original_filename": original,
"storage_path": storage_path, "storage_path": storage_path,
"mime_type": file.content_type, "mime_type": file.content_type,
@@ -139,15 +288,26 @@ async def upload_file(
return RedirectResponse(url="/files", status_code=303) return RedirectResponse(url="/files", status_code=303)
@router.post("/sync")
async def manual_sync(request: Request, db: AsyncSession = Depends(get_db)):
"""Manual sync trigger."""
await sync_files(db)
return RedirectResponse(url="/files", status_code=303)
@router.get("/{file_id}/download") @router.get("/{file_id}/download")
async def download_file(file_id: str, db: AsyncSession = Depends(get_db)): async def download_file(file_id: str, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("files", db) repo = BaseRepository("files", db)
item = await repo.get(file_id) item = await repo.get(file_id)
if not item or not os.path.exists(item["storage_path"]): if not item:
return RedirectResponse(url="/files", status_code=303)
abs_path = _resolve_path(item)
if not os.path.exists(abs_path):
return RedirectResponse(url="/files", status_code=303) return RedirectResponse(url="/files", status_code=303)
return FileResponse( return FileResponse(
path=item["storage_path"], path=abs_path,
filename=item["original_filename"], filename=item["original_filename"],
media_type=item.get("mime_type") or "application/octet-stream", media_type=item.get("mime_type") or "application/octet-stream",
) )
@@ -163,10 +323,12 @@ async def preview_file(file_id: str, request: Request, db: AsyncSession = Depend
return RedirectResponse(url="/files", status_code=303) return RedirectResponse(url="/files", status_code=303)
can_preview = item.get("mime_type", "") in PREVIEWABLE can_preview = item.get("mime_type", "") in PREVIEWABLE
folder = os.path.dirname(item["storage_path"])
return templates.TemplateResponse("file_preview.html", { return templates.TemplateResponse("file_preview.html", {
"request": request, "sidebar": sidebar, "item": item, "request": request, "sidebar": sidebar, "item": item,
"can_preview": can_preview, "can_preview": can_preview,
"folder": folder if folder else "/",
"page_title": item["original_filename"], "active_nav": "files", "page_title": item["original_filename"], "active_nav": "files",
}) })
@@ -176,13 +338,34 @@ async def serve_file(file_id: str, db: AsyncSession = Depends(get_db)):
"""Serve file inline (for img src, iframe, etc).""" """Serve file inline (for img src, iframe, etc)."""
repo = BaseRepository("files", db) repo = BaseRepository("files", db)
item = await repo.get(file_id) item = await repo.get(file_id)
if not item or not os.path.exists(item["storage_path"]): if not item:
return RedirectResponse(url="/files", status_code=303) return RedirectResponse(url="/files", status_code=303)
return FileResponse( abs_path = _resolve_path(item)
path=item["storage_path"], if not os.path.exists(abs_path):
media_type=item.get("mime_type") or "application/octet-stream", return RedirectResponse(url="/files", status_code=303)
mime = item.get("mime_type") or "application/octet-stream"
# Wrap text files in HTML with forced white background / dark text
if mime.startswith("text/"):
try:
with open(abs_path, "r", errors="replace") as f:
text_content = f.read()
except Exception:
return FileResponse(path=abs_path, media_type=mime)
from html import escape
html = (
'<!DOCTYPE html><html><head><meta charset="utf-8">'
'<style>body{background:#fff;color:#1a1a1a;font-family:monospace;'
'font-size:14px;padding:16px;margin:0;white-space:pre-wrap;'
'word-wrap:break-word;}</style></head><body>'
f'{escape(text_content)}</body></html>'
) )
return HTMLResponse(content=html)
return FileResponse(path=abs_path, media_type=mime)
@router.post("/{file_id}/delete") @router.post("/{file_id}/delete")

View File

@@ -17,7 +17,14 @@ templates = Jinja2Templates(directory="templates")
@router.get("/") @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) sidebar = await get_sidebar_data(db)
target_date = date.fromisoformat(focus_date) if focus_date else date.today() 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] items = [dict(r._mapping) for r in result]
# Available tasks to add (open, not already in today's focus) # 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, SELECT t.id, t.title, t.priority, t.due_date,
p.name as project_name, d.name as domain_name p.name as project_name, d.name as domain_name
FROM tasks t FROM tasks t
LEFT JOIN projects p ON t.project_id = p.id LEFT JOIN projects p ON t.project_id = p.id
LEFT JOIN domains d ON t.domain_id = d.id LEFT JOIN domains d ON t.domain_id = d.id
WHERE t.is_deleted = false AND t.status NOT IN ('done', 'cancelled') WHERE {avail_sql}
AND t.id NOT IN (
SELECT task_id FROM daily_focus
WHERE focus_date = :target_date AND is_deleted = false
)
ORDER BY t.priority ASC, t.due_date ASC NULLS LAST ORDER BY t.priority ASC, t.due_date ASC NULLS LAST
LIMIT 50 LIMIT 50
"""), {"target_date": target_date}) """), avail_params)
available_tasks = [dict(r._mapping) for r in result] available_tasks = [dict(r._mapping) for r in result]
# Estimated total minutes # Estimated total minutes
total_est = sum(i.get("estimated_minutes") or 0 for i in items) 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", { return templates.TemplateResponse("focus.html", {
"request": request, "sidebar": sidebar, "request": request, "sidebar": sidebar,
"items": items, "available_tasks": available_tasks, "items": items, "available_tasks": available_tasks,
"focus_date": target_date, "focus_date": target_date,
"total_estimated": total_est, "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", "page_title": "Daily Focus", "active_nav": "focus",
}) })

90
routers/history.py Normal file
View File

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

View File

@@ -1,4 +1,4 @@
"""Links: URL references attached to domains/projects.""" """Links: URL references attached to domains/projects/tasks/meetings."""
from fastapi import APIRouter, Request, Form, Depends from fastapi import APIRouter, Request, Form, Depends
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
@@ -10,6 +10,7 @@ from typing import Optional
from core.database import get_db from core.database import get_db
from core.base_repository import BaseRepository from core.base_repository import BaseRepository
from core.sidebar import get_sidebar_data from core.sidebar import get_sidebar_data
from routers.weblinks import get_default_folder_id
router = APIRouter(prefix="/links", tags=["links"]) router = APIRouter(prefix="/links", tags=["links"])
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
@@ -45,7 +46,14 @@ async def list_links(request: Request, domain_id: Optional[str] = None, db: Asyn
@router.get("/create") @router.get("/create")
async def create_form(request: Request, domain_id: Optional[str] = None, project_id: Optional[str] = None, db: AsyncSession = Depends(get_db)): 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) sidebar = await get_sidebar_data(db)
domains_repo = BaseRepository("domains", db) domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list() domains = await domains_repo.list()
@@ -54,22 +62,48 @@ async def create_form(request: Request, domain_id: Optional[str] = None, project
return templates.TemplateResponse("link_form.html", { return templates.TemplateResponse("link_form.html", {
"request": request, "sidebar": sidebar, "domains": domains, "projects": projects, "request": request, "sidebar": sidebar, "domains": domains, "projects": projects,
"page_title": "New Link", "active_nav": "links", "page_title": "New Link", "active_nav": "links",
"item": None, "prefill_domain_id": domain_id or "", "prefill_project_id": project_id or "", "item": None,
"prefill_domain_id": domain_id or "",
"prefill_project_id": project_id or "",
"prefill_task_id": task_id or "",
"prefill_meeting_id": meeting_id or "",
}) })
@router.post("/create") @router.post("/create")
async def create_link( async def create_link(
request: Request, label: str = Form(...), url: str = Form(...), request: Request, label: str = Form(...), url: str = Form(...),
domain_id: str = Form(...), project_id: Optional[str] = Form(None), domain_id: Optional[str] = Form(None), project_id: Optional[str] = Form(None),
description: Optional[str] = Form(None), db: AsyncSession = Depends(get_db), task_id: Optional[str] = Form(None), meeting_id: Optional[str] = Form(None),
description: Optional[str] = Form(None), tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
): ):
repo = BaseRepository("links", db) repo = BaseRepository("links", db)
data = {"label": label, "url": url, "domain_id": domain_id, "description": description} data = {"label": label, "url": url, "description": description}
if domain_id and domain_id.strip():
data["domain_id"] = domain_id
if project_id and project_id.strip(): if project_id and project_id.strip():
data["project_id"] = project_id data["project_id"] = project_id
await repo.create(data) if task_id and task_id.strip():
referer = request.headers.get("referer", "/links") data["task_id"] = task_id
if meeting_id and meeting_id.strip():
data["meeting_id"] = meeting_id
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
link = await repo.create(data)
# Assign to Default folder
default_fid = await get_default_folder_id(db)
await db.execute(text("""
INSERT INTO folder_links (folder_id, link_id)
VALUES (:fid, :lid) ON CONFLICT DO NOTHING
"""), {"fid": default_fid, "lid": link["id"]})
# Redirect back to context if created from task/meeting
if task_id and task_id.strip():
return RedirectResponse(url=f"/tasks/{task_id}?tab=links", status_code=303)
if meeting_id and meeting_id.strip():
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=links", status_code=303)
return RedirectResponse(url="/links", status_code=303) return RedirectResponse(url="/links", status_code=303)
@@ -87,22 +121,31 @@ async def edit_form(link_id: str, request: Request, db: AsyncSession = Depends(g
return templates.TemplateResponse("link_form.html", { return templates.TemplateResponse("link_form.html", {
"request": request, "sidebar": sidebar, "domains": domains, "projects": projects, "request": request, "sidebar": sidebar, "domains": domains, "projects": projects,
"page_title": "Edit Link", "active_nav": "links", "page_title": "Edit Link", "active_nav": "links",
"item": item, "prefill_domain_id": "", "prefill_project_id": "", "item": item,
"prefill_domain_id": "", "prefill_project_id": "",
"prefill_task_id": "", "prefill_meeting_id": "",
}) })
@router.post("/{link_id}/edit") @router.post("/{link_id}/edit")
async def update_link( async def update_link(
link_id: str, label: str = Form(...), url: str = Form(...), link_id: str, label: str = Form(...), url: str = Form(...),
domain_id: str = Form(...), project_id: Optional[str] = Form(None), domain_id: Optional[str] = Form(None), project_id: Optional[str] = Form(None),
description: Optional[str] = Form(None), db: AsyncSession = Depends(get_db), description: Optional[str] = Form(None), tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
): ):
repo = BaseRepository("links", db) repo = BaseRepository("links", db)
await repo.update(link_id, { data = {
"label": label, "url": url, "domain_id": domain_id, "label": label, "url": url,
"domain_id": domain_id if domain_id and domain_id.strip() else None,
"project_id": project_id if project_id and project_id.strip() else None, "project_id": project_id if project_id and project_id.strip() else None,
"description": description, "description": description,
}) }
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
else:
data["tags"] = None
await repo.update(link_id, data)
return RedirectResponse(url="/links", status_code=303) return RedirectResponse(url="/links", status_code=303)

View File

@@ -70,6 +70,8 @@ async def create_form(
request: Request, request: Request,
domain_id: Optional[str] = None, domain_id: Optional[str] = None,
project_id: Optional[str] = None, project_id: Optional[str] = None,
task_id: Optional[str] = None,
meeting_id: Optional[str] = None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
sidebar = await get_sidebar_data(db) sidebar = await get_sidebar_data(db)
@@ -87,6 +89,8 @@ async def create_form(
"item": None, "item": None,
"prefill_domain_id": domain_id or "", "prefill_domain_id": domain_id or "",
"prefill_project_id": project_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(...), domain_id: str = Form(...),
area_id: Optional[str] = Form(None), area_id: Optional[str] = Form(None),
project_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"), list_type: str = Form("checklist"),
description: Optional[str] = Form(None), description: Optional[str] = Form(None),
tags: Optional[str] = Form(None), tags: Optional[str] = Form(None),
@@ -112,10 +118,18 @@ async def create_list(
data["area_id"] = area_id data["area_id"] = area_id
if project_id and project_id.strip(): if project_id and project_id.strip():
data["project_id"] = project_id 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(): if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()] data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
new_list = await repo.create(data) 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) 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: if pid:
child_map.setdefault(str(pid), []).append(i) 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", { return templates.TemplateResponse("list_detail.html", {
"request": request, "sidebar": sidebar, "item": item, "request": request, "sidebar": sidebar, "item": item,
"domain": domain, "project": project, "domain": domain, "project": project,
"list_items": top_items, "child_map": child_map, "list_items": top_items, "child_map": child_map,
"contacts": contacts, "all_contacts": all_contacts,
"page_title": item["name"], "active_nav": "lists", "page_title": item["name"], "active_nav": "lists",
}) })
@@ -273,3 +305,30 @@ async def edit_item(
repo = BaseRepository("list_items", db) repo = BaseRepository("list_items", db)
await repo.update(item_id, {"content": content}) await repo.update(item_id, {"content": content})
return RedirectResponse(url=f"/lists/{list_id}", status_code=303) 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,14 +107,38 @@ async def create_meeting(
@router.get("/{meeting_id}") @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) repo = BaseRepository("meetings", db)
sidebar = await get_sidebar_data(db) sidebar = await get_sidebar_data(db)
item = await repo.get(meeting_id) item = await repo.get(meeting_id)
if not item: if not item:
return RedirectResponse(url="/meetings", status_code=303) return RedirectResponse(url="/meetings", status_code=303)
# Action items (tasks linked to this meeting) # Linked projects (always shown in header)
result = await db.execute(text("""
SELECT p.id, p.name, d.color as domain_color
FROM projects p
JOIN project_meetings pm ON pm.project_id = p.id
LEFT JOIN domains d ON p.domain_id = d.id
WHERE pm.meeting_id = :mid AND p.is_deleted = false
ORDER BY p.name
"""), {"mid": meeting_id})
projects = [dict(r._mapping) for r in result]
# Overview data (always needed for overview tab)
action_items = []
decisions = []
domains = []
tab_data = []
all_contacts = []
all_decisions = []
if tab == "overview":
# Action items
result = await db.execute(text(""" result = await db.execute(text("""
SELECT t.*, mt.source, SELECT t.*, mt.source,
d.name as domain_name, d.color as domain_color, d.name as domain_name, d.color as domain_color,
@@ -128,14 +152,6 @@ async def meeting_detail(meeting_id: str, request: Request, db: AsyncSession = D
"""), {"mid": meeting_id}) """), {"mid": meeting_id})
action_items = [dict(r._mapping) for r in result] action_items = [dict(r._mapping) for r in result]
# 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]
# Decisions from this meeting # Decisions from this meeting
result = await db.execute(text(""" result = await db.execute(text("""
SELECT * FROM decisions SELECT * FROM decisions
@@ -144,24 +160,94 @@ async def meeting_detail(meeting_id: str, request: Request, db: AsyncSession = D
"""), {"mid": meeting_id}) """), {"mid": meeting_id})
decisions = [dict(r._mapping) for r in result] 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 for action item creation
domains_repo = BaseRepository("domains", db) domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list() domains = await domains_repo.list()
elif tab == "notes":
result = await db.execute(text("""
SELECT * FROM notes
WHERE meeting_id = :mid AND is_deleted = false
ORDER BY updated_at DESC
"""), {"mid": meeting_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "links":
result = await db.execute(text("""
SELECT * FROM links
WHERE meeting_id = :mid AND is_deleted = false
ORDER BY sort_order, label
"""), {"mid": meeting_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "files":
result = await db.execute(text("""
SELECT f.* FROM files f
JOIN file_mappings fm ON fm.file_id = f.id
WHERE fm.context_type = 'meeting' AND fm.context_id = :mid AND f.is_deleted = false
ORDER BY f.created_at DESC
"""), {"mid": meeting_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "lists":
result = await db.execute(text("""
SELECT l.*,
(SELECT count(*) FROM list_items li WHERE li.list_id = l.id AND li.is_deleted = false) as item_count
FROM lists l
WHERE l.meeting_id = :mid AND l.is_deleted = false
ORDER BY l.sort_order, l.created_at DESC
"""), {"mid": meeting_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "decisions":
result = await db.execute(text("""
SELECT * FROM decisions
WHERE meeting_id = :mid AND is_deleted = false
ORDER BY created_at DESC
"""), {"mid": meeting_id})
tab_data = [dict(r._mapping) for r in result]
result = await db.execute(text("""
SELECT id, title FROM decisions
WHERE (meeting_id IS NULL) AND is_deleted = false
ORDER BY created_at DESC LIMIT 50
"""))
all_decisions = [dict(r._mapping) for r in result]
elif tab == "contacts":
result = await db.execute(text("""
SELECT c.*, cm.role, cm.created_at as linked_at
FROM contacts c
JOIN contact_meetings cm ON cm.contact_id = c.id
WHERE cm.meeting_id = :mid AND c.is_deleted = false
ORDER BY c.first_name
"""), {"mid": meeting_id})
tab_data = [dict(r._mapping) for r in result]
result = await db.execute(text("""
SELECT id, first_name, last_name FROM contacts
WHERE is_deleted = false ORDER BY first_name
"""))
all_contacts = [dict(r._mapping) for r in result]
# Tab counts
counts = {}
for count_tab, count_sql in [
("notes", "SELECT count(*) FROM notes WHERE meeting_id = :mid AND is_deleted = false"),
("links", "SELECT count(*) FROM links WHERE meeting_id = :mid AND is_deleted = false"),
("files", "SELECT count(*) FROM files f JOIN file_mappings fm ON fm.file_id = f.id WHERE fm.context_type = 'meeting' AND fm.context_id = :mid AND f.is_deleted = false"),
("lists", "SELECT count(*) FROM lists WHERE meeting_id = :mid AND is_deleted = false"),
("decisions", "SELECT count(*) FROM decisions WHERE meeting_id = :mid AND is_deleted = false"),
("contacts", "SELECT count(*) FROM contacts c JOIN contact_meetings cm ON cm.contact_id = c.id WHERE cm.meeting_id = :mid AND c.is_deleted = false"),
]:
result = await db.execute(text(count_sql), {"mid": meeting_id})
counts[count_tab] = result.scalar() or 0
return templates.TemplateResponse("meeting_detail.html", { return templates.TemplateResponse("meeting_detail.html", {
"request": request, "sidebar": sidebar, "item": item, "request": request, "sidebar": sidebar, "item": item,
"action_items": action_items, "meeting_notes": meeting_notes, "action_items": action_items, "decisions": decisions,
"decisions": decisions, "attendees": attendees, "domains": domains, "projects": projects,
"domains": domains, "tab": tab, "tab_data": tab_data,
"all_contacts": all_contacts, "all_decisions": all_decisions,
"counts": counts,
"page_title": item["title"], "active_nav": "meetings", "page_title": item["title"], "active_nav": "meetings",
}) })
@@ -266,3 +352,55 @@ async def create_action_item(
"""), {"mid": meeting_id, "tid": task["id"]}) """), {"mid": meeting_id, "tid": task["id"]})
return RedirectResponse(url=f"/meetings/{meeting_id}", status_code=303) return RedirectResponse(url=f"/meetings/{meeting_id}", status_code=303)
# ---- Decision linking ----
@router.post("/{meeting_id}/decisions/add")
async def add_decision(
meeting_id: str,
decision_id: str = Form(...),
db: AsyncSession = Depends(get_db),
):
await db.execute(text("""
UPDATE decisions SET meeting_id = :mid WHERE id = :did
"""), {"mid": meeting_id, "did": decision_id})
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=decisions", status_code=303)
@router.post("/{meeting_id}/decisions/{decision_id}/remove")
async def remove_decision(
meeting_id: str, decision_id: str,
db: AsyncSession = Depends(get_db),
):
await db.execute(text("""
UPDATE decisions SET meeting_id = NULL WHERE id = :did AND meeting_id = :mid
"""), {"did": decision_id, "mid": meeting_id})
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=decisions", status_code=303)
# ---- Contact linking ----
@router.post("/{meeting_id}/contacts/add")
async def add_contact(
meeting_id: str,
contact_id: str = Form(...),
role: str = Form("attendee"),
db: AsyncSession = Depends(get_db),
):
await db.execute(text("""
INSERT INTO contact_meetings (contact_id, meeting_id, role)
VALUES (:cid, :mid, :role) ON CONFLICT DO NOTHING
"""), {"cid": contact_id, "mid": meeting_id, "role": role if role and role.strip() else "attendee"})
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=contacts", status_code=303)
@router.post("/{meeting_id}/contacts/{contact_id}/remove")
async def remove_contact(
meeting_id: str, contact_id: str,
db: AsyncSession = Depends(get_db),
):
await db.execute(text(
"DELETE FROM contact_meetings WHERE contact_id = :cid AND meeting_id = :mid"
), {"cid": contact_id, "mid": meeting_id})
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=contacts", status_code=303)

View File

@@ -61,6 +61,8 @@ async def create_form(
request: Request, request: Request,
domain_id: Optional[str] = None, domain_id: Optional[str] = None,
project_id: Optional[str] = None, project_id: Optional[str] = None,
task_id: Optional[str] = None,
meeting_id: Optional[str] = None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
sidebar = await get_sidebar_data(db) sidebar = await get_sidebar_data(db)
@@ -75,6 +77,8 @@ async def create_form(
"item": None, "item": None,
"prefill_domain_id": domain_id or "", "prefill_domain_id": domain_id or "",
"prefill_project_id": project_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(...), title: str = Form(...),
domain_id: str = Form(...), domain_id: str = Form(...),
project_id: Optional[str] = Form(None), project_id: Optional[str] = Form(None),
task_id: Optional[str] = Form(None),
meeting_id: Optional[str] = Form(None),
body: Optional[str] = Form(None), body: Optional[str] = Form(None),
content_format: str = Form("rich"), content_format: str = Form("rich"),
tags: Optional[str] = Form(None), tags: Optional[str] = Form(None),
@@ -96,9 +102,17 @@ async def create_note(
} }
if project_id and project_id.strip(): if project_id and project_id.strip():
data["project_id"] = project_id 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(): if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()] data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
note = await repo.create(data) 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) return RedirectResponse(url=f"/notes/{note['id']}", status_code=303)

569
routers/processes.py Normal file
View File

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

View File

@@ -2,7 +2,7 @@
from fastapi import APIRouter, Request, Form, Depends from fastapi import APIRouter, Request, Form, Depends
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse, JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text from sqlalchemy import text
from typing import Optional from typing import Optional
@@ -15,6 +15,27 @@ router = APIRouter(prefix="/projects", tags=["projects"])
templates = Jinja2Templates(directory="templates") 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("/") @router.get("/")
async def list_projects( async def list_projects(
request: Request, request: Request,
@@ -151,7 +172,7 @@ async def project_detail(
row = result.first() row = result.first()
area = dict(row._mapping) if row else None 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(""" result = await db.execute(text("""
SELECT t.*, d.name as domain_name, d.color as domain_color SELECT t.*, d.name as domain_name, d.color as domain_color
FROM tasks t FROM tasks t
@@ -161,29 +182,110 @@ async def project_detail(
"""), {"pid": project_id}) """), {"pid": project_id})
tasks = [dict(r._mapping) for r in result] tasks = [dict(r._mapping) for r in result]
# Notes # Progress
total = len(tasks)
done = len([t for t in tasks if t["status"] == "done"])
progress = round((done / total * 100) if total > 0 else 0)
# Tab-specific data
notes = []
links = []
tab_data = []
all_contacts = []
all_meetings = []
if tab == "notes":
result = await db.execute(text(""" result = await db.execute(text("""
SELECT * FROM notes WHERE project_id = :pid AND is_deleted = false SELECT * FROM notes WHERE project_id = :pid AND is_deleted = false
ORDER BY sort_order, created_at DESC ORDER BY sort_order, created_at DESC
"""), {"pid": project_id}) """), {"pid": project_id})
notes = [dict(r._mapping) for r in result] notes = [dict(r._mapping) for r in result]
# Links elif tab == "links":
result = await db.execute(text(""" result = await db.execute(text("""
SELECT * FROM links WHERE project_id = :pid AND is_deleted = false SELECT * FROM links WHERE project_id = :pid AND is_deleted = false
ORDER BY sort_order, created_at ORDER BY sort_order, created_at
"""), {"pid": project_id}) """), {"pid": project_id})
links = [dict(r._mapping) for r in result] links = [dict(r._mapping) for r in result]
# Progress elif tab == "files":
total = len(tasks) result = await db.execute(text("""
done = len([t for t in tasks if t["status"] == "done"]) SELECT f.* FROM files f
progress = round((done / total * 100) if total > 0 else 0) JOIN file_mappings fm ON fm.file_id = f.id
WHERE fm.context_type = 'project' AND fm.context_id = :pid AND f.is_deleted = false
ORDER BY f.created_at DESC
"""), {"pid": project_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "lists":
result = await db.execute(text("""
SELECT l.*,
(SELECT count(*) FROM list_items li WHERE li.list_id = l.id AND li.is_deleted = false) as item_count
FROM lists l
WHERE l.project_id = :pid AND l.is_deleted = false
ORDER BY l.sort_order, l.created_at DESC
"""), {"pid": project_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "decisions":
result = await db.execute(text("""
SELECT d.* FROM decisions d
JOIN decision_projects dp ON dp.decision_id = d.id
WHERE dp.project_id = :pid AND d.is_deleted = false
ORDER BY d.created_at DESC
"""), {"pid": project_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "meetings":
result = await db.execute(text("""
SELECT m.*, pm.created_at as linked_at
FROM meetings m
JOIN project_meetings pm ON pm.meeting_id = m.id
WHERE pm.project_id = :pid AND m.is_deleted = false
ORDER BY m.meeting_date DESC, m.start_at DESC NULLS LAST
"""), {"pid": project_id})
tab_data = [dict(r._mapping) for r in result]
result = await db.execute(text("""
SELECT id, title, meeting_date FROM meetings
WHERE is_deleted = false ORDER BY meeting_date DESC LIMIT 50
"""))
all_meetings = [dict(r._mapping) for r in result]
elif tab == "contacts":
result = await db.execute(text("""
SELECT c.*, cp.role, cp.created_at as linked_at
FROM contacts c
JOIN contact_projects cp ON cp.contact_id = c.id
WHERE cp.project_id = :pid AND c.is_deleted = false
ORDER BY c.first_name
"""), {"pid": project_id})
tab_data = [dict(r._mapping) for r in result]
result = await db.execute(text("""
SELECT id, first_name, last_name FROM contacts
WHERE is_deleted = false ORDER BY first_name
"""))
all_contacts = [dict(r._mapping) for r in result]
# Tab counts
counts = {}
for count_tab, count_sql in [
("notes", "SELECT count(*) FROM notes WHERE project_id = :pid AND is_deleted = false"),
("links", "SELECT count(*) FROM links WHERE project_id = :pid AND is_deleted = false"),
("files", "SELECT count(*) FROM files f JOIN file_mappings fm ON fm.file_id = f.id WHERE fm.context_type = 'project' AND fm.context_id = :pid AND f.is_deleted = false"),
("lists", "SELECT count(*) FROM lists WHERE project_id = :pid AND is_deleted = false"),
("decisions", "SELECT count(*) FROM decisions d JOIN decision_projects dp ON dp.decision_id = d.id WHERE dp.project_id = :pid AND d.is_deleted = false"),
("meetings", "SELECT count(*) FROM meetings m JOIN project_meetings pm ON pm.meeting_id = m.id WHERE pm.project_id = :pid AND m.is_deleted = false"),
("contacts", "SELECT count(*) FROM contacts c JOIN contact_projects cp ON cp.contact_id = c.id WHERE cp.project_id = :pid AND c.is_deleted = false"),
]:
result = await db.execute(text(count_sql), {"pid": project_id})
counts[count_tab] = result.scalar() or 0
return templates.TemplateResponse("project_detail.html", { return templates.TemplateResponse("project_detail.html", {
"request": request, "sidebar": sidebar, "item": item, "request": request, "sidebar": sidebar, "item": item,
"domain": domain, "area": area, "domain": domain, "area": area,
"tasks": tasks, "notes": notes, "links": links, "tasks": tasks, "notes": notes, "links": links,
"tab_data": tab_data, "all_contacts": all_contacts,
"all_meetings": all_meetings, "counts": counts,
"progress": progress, "task_count": total, "done_count": done, "progress": progress, "task_count": total, "done_count": done,
"tab": tab, "tab": tab,
"page_title": item["name"], "active_nav": "projects", "page_title": item["name"], "active_nav": "projects",
@@ -246,3 +348,56 @@ async def delete_project(project_id: str, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("projects", db) repo = BaseRepository("projects", db)
await repo.soft_delete(project_id) await repo.soft_delete(project_id)
return RedirectResponse(url="/projects", status_code=303) return RedirectResponse(url="/projects", status_code=303)
# ---- Meeting linking ----
@router.post("/{project_id}/meetings/add")
async def add_meeting(
project_id: str,
meeting_id: str = Form(...),
db: AsyncSession = Depends(get_db),
):
await db.execute(text("""
INSERT INTO project_meetings (project_id, meeting_id)
VALUES (:pid, :mid) ON CONFLICT DO NOTHING
"""), {"pid": project_id, "mid": meeting_id})
return RedirectResponse(url=f"/projects/{project_id}?tab=meetings", status_code=303)
@router.post("/{project_id}/meetings/{meeting_id}/remove")
async def remove_meeting(
project_id: str, meeting_id: str,
db: AsyncSession = Depends(get_db),
):
await db.execute(text(
"DELETE FROM project_meetings WHERE project_id = :pid AND meeting_id = :mid"
), {"pid": project_id, "mid": meeting_id})
return RedirectResponse(url=f"/projects/{project_id}?tab=meetings", status_code=303)
# ---- Contact linking ----
@router.post("/{project_id}/contacts/add")
async def add_contact(
project_id: str,
contact_id: str = Form(...),
role: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
await db.execute(text("""
INSERT INTO contact_projects (contact_id, project_id, role)
VALUES (:cid, :pid, :role) ON CONFLICT DO NOTHING
"""), {"cid": contact_id, "pid": project_id, "role": role if role and role.strip() else None})
return RedirectResponse(url=f"/projects/{project_id}?tab=contacts", status_code=303)
@router.post("/{project_id}/contacts/{contact_id}/remove")
async def remove_contact(
project_id: str, contact_id: str,
db: AsyncSession = Depends(get_db),
):
await db.execute(text(
"DELETE FROM contact_projects WHERE contact_id = :cid AND project_id = :pid"
), {"cid": contact_id, "pid": project_id})
return RedirectResponse(url=f"/projects/{project_id}?tab=contacts", status_code=303)

View File

@@ -1,5 +1,6 @@
"""Global search: Cmd/K modal, tsvector full-text search across all entities.""" """Global search: Cmd/K modal, tsvector full-text search across all entities."""
import re
from fastapi import APIRouter, Request, Depends, Query from fastapi import APIRouter, Request, Depends, Query
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@@ -14,160 +15,155 @@ router = APIRouter(prefix="/search", tags=["search"])
templates = Jinja2Templates(directory="templates") 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 = [ SEARCH_ENTITIES = [
{ {
"type": "tasks", "type": "tasks", "label": "Tasks", "table": "tasks", "alias": "t",
"label": "Tasks", "name_col": "t.title", "status_col": "t.status",
"query": """ "joins": "LEFT JOIN domains d ON t.domain_id = d.id LEFT JOIN projects p ON t.project_id = p.id",
SELECT t.id, t.title as name, t.status, "domain_col": "d.name", "project_col": "p.name",
d.name as domain_name, p.name as project_name, "url": "/tasks/{id}", "icon": "task",
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": "projects", "type": "projects", "label": "Projects", "table": "projects", "alias": "p",
"label": "Projects", "name_col": "p.name", "status_col": "p.status",
"query": """ "joins": "LEFT JOIN domains d ON p.domain_id = d.id",
SELECT p.id, p.name, p.status, "domain_col": "d.name", "project_col": "NULL",
d.name as domain_name, NULL as project_name, "url": "/projects/{id}", "icon": "project",
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": "notes", "type": "notes", "label": "Notes", "table": "notes", "alias": "n",
"label": "Notes", "name_col": "n.title", "status_col": "NULL",
"query": """ "joins": "LEFT JOIN domains d ON n.domain_id = d.id LEFT JOIN projects p ON n.project_id = p.id",
SELECT n.id, n.title as name, NULL as status, "domain_col": "d.name", "project_col": "p.name",
d.name as domain_name, p.name as project_name, "url": "/notes/{id}", "icon": "note",
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": "contacts", "type": "contacts", "label": "Contacts", "table": "contacts", "alias": "c",
"label": "Contacts", "name_col": "(c.first_name || ' ' || coalesce(c.last_name, ''))", "status_col": "NULL",
"query": """ "joins": "",
SELECT c.id, (c.first_name || ' ' || coalesce(c.last_name, '')) as name, "domain_col": "c.company", "project_col": "NULL",
NULL as status, c.company as domain_name, NULL as project_name, "url": "/contacts/{id}", "icon": "contact",
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": "links", "type": "links", "label": "Links", "table": "links", "alias": "l",
"label": "Links", "name_col": "l.label", "status_col": "NULL",
"query": """ "joins": "LEFT JOIN domains d ON l.domain_id = d.id LEFT JOIN projects p ON l.project_id = p.id",
SELECT l.id, l.label as name, NULL as status, "domain_col": "d.name", "project_col": "p.name",
d.name as domain_name, p.name as project_name, "url": "/links", "icon": "link",
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": "lists", "type": "lists", "label": "Lists", "table": "lists", "alias": "l",
"label": "Lists", "name_col": "l.name", "status_col": "NULL",
"query": """ "joins": "LEFT JOIN domains d ON l.domain_id = d.id LEFT JOIN projects p ON l.project_id = p.id",
SELECT l.id, l.name, NULL as status, "domain_col": "d.name", "project_col": "p.name",
d.name as domain_name, p.name as project_name, "url": "/lists/{id}", "icon": "list",
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": "meetings", "type": "meetings", "label": "Meetings", "table": "meetings", "alias": "m",
"label": "Meetings", "name_col": "m.title", "status_col": "m.status",
"query": """ "joins": "",
SELECT m.id, m.title as name, m.status, "domain_col": "NULL", "project_col": "NULL",
NULL as domain_name, NULL as project_name, "url": "/meetings/{id}", "icon": "meeting",
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": "decisions", "type": "decisions", "label": "Decisions", "table": "decisions", "alias": "d",
"label": "Decisions", "name_col": "d.title", "status_col": "d.status",
"query": """ "joins": "",
SELECT d.id, d.title as name, d.status, "domain_col": "NULL", "project_col": "NULL",
NULL as domain_name, NULL as project_name, "url": "/decisions/{id}", "icon": "decision",
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": "weblinks", "type": "processes", "label": "Processes", "table": "processes", "alias": "p",
"label": "Weblinks", "name_col": "p.name", "status_col": "p.status",
"query": """ "joins": "",
SELECT w.id, w.label as name, NULL as status, "domain_col": "p.category", "project_col": "NULL",
NULL as domain_name, NULL as project_name, "url": "/processes/{id}", "icon": "process",
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": "appointments", "type": "appointments", "label": "Appointments", "table": "appointments", "alias": "a",
"label": "Appointments", "name_col": "a.title", "status_col": "NULL",
"query": """ "joins": "",
SELECT a.id, a.title as name, NULL as status, "domain_col": "a.location", "project_col": "NULL",
a.location as domain_name, NULL as project_name, "url": "/appointments/{id}", "icon": "appointment",
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",
}, },
] ]
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") @router.get("/api")
async def search_api( async def search_api(
q: str = Query("", min_length=1), q: str = Query("", min_length=1),
@@ -176,18 +172,18 @@ async def search_api(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""JSON search endpoint for the Cmd/K modal.""" """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}) return JSONResponse({"results": [], "query": q})
tsquery_str = build_prefix_tsquery(q)
results = [] results = []
entities = SEARCH_ENTITIES entities = SEARCH_ENTITIES
if entity_type: if entity_type:
entities = [e for e in entities if e["type"] == entity_type] entities = [e for e in entities if e["type"] == entity_type]
for entity in entities: for entity in entities:
try: rows = await _search_entity(entity, q, tsquery_str, limit, db)
result = await db.execute(text(entity["query"]), {"q": q.strip(), "lim": limit})
rows = [dict(r._mapping) for r in result]
for row in rows: for row in rows:
results.append({ results.append({
"type": entity["type"], "type": entity["type"],
@@ -200,9 +196,6 @@ async def search_api(
"rank": float(row.get("rank", 0)), "rank": float(row.get("rank", 0)),
"icon": entity["icon"], "icon": entity["icon"],
}) })
except Exception:
# Table might not have search_vector yet, skip silently
continue
# Sort all results by rank descending # Sort all results by rank descending
results.sort(key=lambda r: r["rank"], reverse=True) results.sort(key=lambda r: r["rank"], reverse=True)
@@ -220,11 +213,11 @@ async def search_page(
sidebar = await get_sidebar_data(db) sidebar = await get_sidebar_data(db)
results = [] 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: for entity in SEARCH_ENTITIES:
try: rows = await _search_entity(entity, q, tsquery_str, 10, db)
result = await db.execute(text(entity["query"]), {"q": q.strip(), "lim": 10})
rows = [dict(r._mapping) for r in result]
for row in rows: for row in rows:
results.append({ results.append({
"type": entity["type"], "type": entity["type"],
@@ -236,8 +229,6 @@ async def search_page(
"url": entity["url"].format(id=row["id"]), "url": entity["url"].format(id=row["id"]),
"icon": entity["icon"], "icon": entity["icon"],
}) })
except Exception:
continue
return templates.TemplateResponse("search.html", { return templates.TemplateResponse("search.html", {
"request": request, "sidebar": sidebar, "request": request, "sidebar": sidebar,

View File

@@ -186,7 +186,11 @@ async def create_task(
@router.get("/{task_id}") @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) repo = BaseRepository("tasks", db)
sidebar = await get_sidebar_data(db) sidebar = await get_sidebar_data(db)
item = await repo.get(task_id) item = await repo.get(task_id)
@@ -212,7 +216,9 @@ async def task_detail(task_id: str, request: Request, db: AsyncSession = Depends
row = result.first() row = result.first()
parent = dict(row._mapping) if row else None parent = dict(row._mapping) if row else None
# Subtasks # Subtasks (always needed for overview tab)
subtasks = []
if tab == "overview":
result = await db.execute(text(""" result = await db.execute(text("""
SELECT * FROM tasks WHERE parent_id = :tid AND is_deleted = false SELECT * FROM tasks WHERE parent_id = :tid AND is_deleted = false
ORDER BY sort_order, created_at ORDER BY sort_order, created_at
@@ -221,10 +227,90 @@ async def task_detail(task_id: str, request: Request, db: AsyncSession = Depends
running_task_id = await get_running_task_id(db) running_task_id = await get_running_task_id(db)
# Tab-specific data
tab_data = []
all_contacts = []
if tab == "notes":
result = await db.execute(text("""
SELECT * FROM notes WHERE task_id = :tid AND is_deleted = false
ORDER BY updated_at DESC
"""), {"tid": task_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "links":
result = await db.execute(text("""
SELECT * FROM links WHERE task_id = :tid AND is_deleted = false
ORDER BY sort_order, label
"""), {"tid": task_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "files":
result = await db.execute(text("""
SELECT f.* FROM files f
JOIN file_mappings fm ON fm.file_id = f.id
WHERE fm.context_type = 'task' AND fm.context_id = :tid AND f.is_deleted = false
ORDER BY f.created_at DESC
"""), {"tid": task_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "lists":
result = await db.execute(text("""
SELECT l.*,
(SELECT count(*) FROM list_items li WHERE li.list_id = l.id AND li.is_deleted = false) as item_count
FROM lists l
WHERE l.task_id = :tid AND l.is_deleted = false
ORDER BY l.sort_order, l.created_at DESC
"""), {"tid": task_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "decisions":
result = await db.execute(text("""
SELECT * FROM decisions WHERE task_id = :tid AND is_deleted = false
ORDER BY created_at DESC
"""), {"tid": task_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "contacts":
result = await db.execute(text("""
SELECT c.*, ct.role, ct.created_at as linked_at
FROM contacts c
JOIN contact_tasks ct ON ct.contact_id = c.id
WHERE ct.task_id = :tid AND c.is_deleted = false
ORDER BY c.first_name
"""), {"tid": task_id})
tab_data = [dict(r._mapping) for r in result]
# All contacts for add dropdown
result = await db.execute(text("""
SELECT id, first_name, last_name FROM contacts
WHERE is_deleted = false ORDER BY first_name
"""))
all_contacts = [dict(r._mapping) for r in result]
# Tab counts for badges
counts = {}
for count_tab, count_sql in [
("notes", "SELECT count(*) FROM notes WHERE task_id = :tid AND is_deleted = false"),
("links", "SELECT count(*) FROM links WHERE task_id = :tid AND is_deleted = false"),
("files", "SELECT count(*) FROM files f JOIN file_mappings fm ON fm.file_id = f.id WHERE fm.context_type = 'task' AND fm.context_id = :tid AND f.is_deleted = false"),
("lists", "SELECT count(*) FROM lists WHERE task_id = :tid AND is_deleted = false"),
("decisions", "SELECT count(*) FROM decisions WHERE task_id = :tid AND is_deleted = false"),
("contacts", "SELECT count(*) FROM contacts c JOIN contact_tasks ct ON ct.contact_id = c.id WHERE ct.task_id = :tid AND c.is_deleted = false"),
]:
result = await db.execute(text(count_sql), {"tid": task_id})
counts[count_tab] = result.scalar() or 0
# Subtask count for overview badge
result = await db.execute(text(
"SELECT count(*) FROM tasks WHERE parent_id = :tid AND is_deleted = false"
), {"tid": task_id})
counts["overview"] = result.scalar() or 0
return templates.TemplateResponse("task_detail.html", { return templates.TemplateResponse("task_detail.html", {
"request": request, "sidebar": sidebar, "item": item, "request": request, "sidebar": sidebar, "item": item,
"domain": domain, "project": project, "parent": parent, "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, "running_task_id": running_task_id,
"page_title": item["title"], "active_nav": "tasks", "page_title": item["title"], "active_nav": "tasks",
}) })
@@ -368,3 +454,30 @@ async def quick_add(
await repo.create(data) await repo.create(data)
referer = request.headers.get("referer", "/tasks") referer = request.headers.get("referer", "/tasks")
return RedirectResponse(url=referer, status_code=303) return RedirectResponse(url=referer, status_code=303)
# ---- Contact linking ----
@router.post("/{task_id}/contacts/add")
async def add_contact(
task_id: str,
contact_id: str = Form(...),
role: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
await db.execute(text("""
INSERT INTO contact_tasks (contact_id, task_id, role)
VALUES (:cid, :tid, :role) ON CONFLICT DO NOTHING
"""), {"cid": contact_id, "tid": task_id, "role": role if role and role.strip() else None})
return RedirectResponse(url=f"/tasks/{task_id}?tab=contacts", status_code=303)
@router.post("/{task_id}/contacts/{contact_id}/remove")
async def remove_contact(
task_id: str, contact_id: str,
db: AsyncSession = Depends(get_db),
):
await db.execute(text(
"DELETE FROM contact_tasks WHERE contact_id = :cid AND task_id = :tid"
), {"cid": contact_id, "tid": task_id})
return RedirectResponse(url=f"/tasks/{task_id}?tab=contacts", status_code=303)

189
routers/time_budgets.py Normal file
View File

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

View File

@@ -1,4 +1,4 @@
"""Weblinks: organized bookmark directory with recursive folders.""" """Bookmarks: organized folder directory for links."""
from fastapi import APIRouter, Request, Form, Depends from fastapi import APIRouter, Request, Form, Depends
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
@@ -11,12 +11,25 @@ from core.database import get_db
from core.base_repository import BaseRepository from core.base_repository import BaseRepository
from core.sidebar import get_sidebar_data from core.sidebar import get_sidebar_data
router = APIRouter(prefix="/weblinks", tags=["weblinks"]) router = APIRouter(prefix="/weblinks", tags=["bookmarks"])
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
async def get_default_folder_id(db: AsyncSession) -> str:
"""Return the Default folder id, creating it if it doesn't exist."""
result = await db.execute(text(
"SELECT id FROM link_folders WHERE name = 'Default' AND is_deleted = false ORDER BY created_at LIMIT 1"
))
row = result.first()
if row:
return str(row[0])
repo = BaseRepository("link_folders", db)
folder = await repo.create({"name": "Default", "sort_order": 0})
return str(folder["id"])
@router.get("/") @router.get("/")
async def list_weblinks( async def list_bookmarks(
request: Request, request: Request,
folder_id: Optional[str] = None, folder_id: Optional[str] = None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -25,10 +38,10 @@ async def list_weblinks(
# Get all folders for tree nav # Get all folders for tree nav
result = await db.execute(text(""" result = await db.execute(text("""
SELECT wf.*, (SELECT count(*) FROM folder_weblinks fw WHERE fw.folder_id = wf.id) as link_count SELECT lf.*, (SELECT count(*) FROM folder_links fl WHERE fl.folder_id = lf.id) as link_count
FROM weblink_folders wf FROM link_folders lf
WHERE wf.is_deleted = false WHERE lf.is_deleted = false
ORDER BY wf.sort_order, wf.name ORDER BY lf.sort_order, lf.name
""")) """))
all_folders = [dict(r._mapping) for r in result] all_folders = [dict(r._mapping) for r in result]
@@ -48,20 +61,31 @@ async def list_weblinks(
current_folder = f current_folder = f
break break
# Get weblinks (filtered by folder or all unfiled) # Get links (filtered by folder or all)
available_links = []
if folder_id: if folder_id:
result = await db.execute(text(""" result = await db.execute(text("""
SELECT w.* FROM weblinks w SELECT l.* FROM links l
JOIN folder_weblinks fw ON fw.weblink_id = w.id JOIN folder_links fl ON fl.link_id = l.id
WHERE fw.folder_id = :fid AND w.is_deleted = false WHERE fl.folder_id = :fid AND l.is_deleted = false
ORDER BY fw.sort_order, w.label ORDER BY fl.sort_order, l.label
"""), {"fid": folder_id}) """), {"fid": folder_id})
else: items = [dict(r._mapping) for r in result]
# Show all weblinks
# Links NOT in this folder (for "add existing" dropdown)
result = await db.execute(text(""" result = await db.execute(text("""
SELECT w.* FROM weblinks w SELECT l.id, l.label FROM links l
WHERE w.is_deleted = false WHERE l.is_deleted = false
ORDER BY w.sort_order, w.label AND l.id NOT IN (SELECT link_id FROM folder_links WHERE folder_id = :fid)
ORDER BY l.label
"""), {"fid": folder_id})
available_links = [dict(r._mapping) for r in result]
else:
# Show all links
result = await db.execute(text("""
SELECT l.* FROM links l
WHERE l.is_deleted = false
ORDER BY l.sort_order, l.label
""")) """))
items = [dict(r._mapping) for r in result] items = [dict(r._mapping) for r in result]
@@ -70,7 +94,8 @@ async def list_weblinks(
"top_folders": top_folders, "child_folder_map": child_folder_map, "top_folders": top_folders, "child_folder_map": child_folder_map,
"current_folder": current_folder, "current_folder": current_folder,
"current_folder_id": folder_id or "", "current_folder_id": folder_id or "",
"page_title": "Weblinks", "active_nav": "weblinks", "available_links": available_links,
"page_title": "Bookmarks", "active_nav": "bookmarks",
}) })
@@ -78,83 +103,97 @@ async def list_weblinks(
async def create_form( async def create_form(
request: Request, request: Request,
folder_id: Optional[str] = None, folder_id: Optional[str] = None,
task_id: Optional[str] = None,
meeting_id: Optional[str] = None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
sidebar = await get_sidebar_data(db) sidebar = await get_sidebar_data(db)
result = await db.execute(text( result = await db.execute(text(
"SELECT id, name FROM weblink_folders WHERE is_deleted = false ORDER BY name" "SELECT id, name FROM link_folders WHERE is_deleted = false ORDER BY name"
)) ))
folders = [dict(r._mapping) for r in result] folders = [dict(r._mapping) for r in result]
return templates.TemplateResponse("weblink_form.html", { return templates.TemplateResponse("weblink_form.html", {
"request": request, "sidebar": sidebar, "request": request, "sidebar": sidebar,
"folders": folders, "folders": folders,
"page_title": "New Weblink", "active_nav": "weblinks", "page_title": "New Link", "active_nav": "bookmarks",
"item": None, "item": None,
"prefill_folder_id": folder_id or "", "prefill_folder_id": folder_id or "",
"prefill_task_id": task_id or "",
"prefill_meeting_id": meeting_id or "",
}) })
@router.post("/create") @router.post("/create")
async def create_weblink( async def create_link(
request: Request, request: Request,
label: str = Form(...), label: str = Form(...),
url: str = Form(...), url: str = Form(...),
description: Optional[str] = Form(None), description: Optional[str] = Form(None),
folder_id: 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), tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
repo = BaseRepository("weblinks", db) repo = BaseRepository("links", db)
data = {"label": label, "url": url, "description": description} 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(): if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()] data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
weblink = await repo.create(data) link = await repo.create(data)
# Add to folder if specified # Add to folder (default if none specified)
if folder_id and folder_id.strip(): effective_folder = folder_id if folder_id and folder_id.strip() else await get_default_folder_id(db)
await db.execute(text(""" await db.execute(text("""
INSERT INTO folder_weblinks (folder_id, weblink_id) INSERT INTO folder_links (folder_id, link_id)
VALUES (:fid, :wid) ON CONFLICT DO NOTHING VALUES (:fid, :lid) ON CONFLICT DO NOTHING
"""), {"fid": folder_id, "wid": weblink["id"]}) """), {"fid": effective_folder, "lid": link["id"]})
if task_id and task_id.strip():
return RedirectResponse(url=f"/tasks/{task_id}?tab=links", status_code=303)
if meeting_id and meeting_id.strip():
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=links", status_code=303)
redirect_url = f"/weblinks?folder_id={folder_id}" if folder_id and folder_id.strip() else "/weblinks" 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) return RedirectResponse(url=redirect_url, status_code=303)
@router.get("/{weblink_id}/edit") @router.get("/{link_id}/edit")
async def edit_form(weblink_id: str, request: Request, db: AsyncSession = Depends(get_db)): async def edit_form(link_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("weblinks", db) repo = BaseRepository("links", db)
sidebar = await get_sidebar_data(db) sidebar = await get_sidebar_data(db)
item = await repo.get(weblink_id) item = await repo.get(link_id)
if not item: if not item:
return RedirectResponse(url="/weblinks", status_code=303) return RedirectResponse(url="/weblinks", status_code=303)
result = await db.execute(text( result = await db.execute(text(
"SELECT id, name FROM weblink_folders WHERE is_deleted = false ORDER BY name" "SELECT id, name FROM link_folders WHERE is_deleted = false ORDER BY name"
)) ))
folders = [dict(r._mapping) for r in result] folders = [dict(r._mapping) for r in result]
# Current folder assignment # Current folder assignment
result = await db.execute(text( result = await db.execute(text(
"SELECT folder_id FROM folder_weblinks WHERE weblink_id = :wid LIMIT 1" "SELECT folder_id FROM folder_links WHERE link_id = :lid LIMIT 1"
), {"wid": weblink_id}) ), {"lid": link_id})
row = result.first() row = result.first()
current_folder_id = str(row[0]) if row else "" current_folder_id = str(row[0]) if row else ""
return templates.TemplateResponse("weblink_form.html", { return templates.TemplateResponse("weblink_form.html", {
"request": request, "sidebar": sidebar, "request": request, "sidebar": sidebar,
"folders": folders, "folders": folders,
"page_title": "Edit Weblink", "active_nav": "weblinks", "page_title": "Edit Link", "active_nav": "bookmarks",
"item": item, "item": item,
"prefill_folder_id": current_folder_id, "prefill_folder_id": current_folder_id,
}) })
@router.post("/{weblink_id}/edit") @router.post("/{link_id}/edit")
async def update_weblink( async def update_link(
weblink_id: str, link_id: str,
label: str = Form(...), label: str = Form(...),
url: str = Form(...), url: str = Form(...),
description: Optional[str] = Form(None), description: Optional[str] = Form(None),
@@ -162,7 +201,7 @@ async def update_weblink(
tags: Optional[str] = Form(None), tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
repo = BaseRepository("weblinks", db) repo = BaseRepository("links", db)
data = { data = {
"label": label, "url": url, "label": label, "url": url,
"description": description if description and description.strip() else None, "description": description if description and description.strip() else None,
@@ -172,41 +211,131 @@ async def update_weblink(
else: else:
data["tags"] = None data["tags"] = None
await repo.update(weblink_id, data) await repo.update(link_id, data)
# Update folder assignment # Update folder assignment
await db.execute(text("DELETE FROM folder_weblinks WHERE weblink_id = :wid"), {"wid": weblink_id}) await db.execute(text("DELETE FROM folder_links WHERE link_id = :lid"), {"lid": link_id})
if folder_id and folder_id.strip(): if folder_id and folder_id.strip():
await db.execute(text(""" await db.execute(text("""
INSERT INTO folder_weblinks (folder_id, weblink_id) INSERT INTO folder_links (folder_id, link_id)
VALUES (:fid, :wid) ON CONFLICT DO NOTHING VALUES (:fid, :lid) ON CONFLICT DO NOTHING
"""), {"fid": folder_id, "wid": weblink_id}) """), {"fid": folder_id, "lid": link_id})
return RedirectResponse(url="/weblinks", status_code=303) return RedirectResponse(url="/weblinks", status_code=303)
@router.post("/{weblink_id}/delete") @router.post("/{link_id}/delete")
async def delete_weblink(weblink_id: str, request: Request, db: AsyncSession = Depends(get_db)): async def delete_link(link_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("weblinks", db) repo = BaseRepository("links", db)
await repo.soft_delete(weblink_id) await repo.soft_delete(link_id)
referer = request.headers.get("referer", "/weblinks") referer = request.headers.get("referer", "/weblinks")
return RedirectResponse(url=referer, status_code=303) return RedirectResponse(url=referer, status_code=303)
# ---- Reorder links within a folder ----
@router.post("/folders/{folder_id}/reorder")
async def reorder_link(
folder_id: str,
link_id: str = Form(...),
direction: str = Form(...),
db: AsyncSession = Depends(get_db),
):
# Get all folder_links for this folder, ordered by sort_order then created_at
result = await db.execute(text("""
SELECT link_id, sort_order FROM folder_links
WHERE folder_id = :fid
ORDER BY sort_order, created_at
"""), {"fid": folder_id})
rows = [dict(r._mapping) for r in result]
if not rows:
return RedirectResponse(url=f"/weblinks?folder_id={folder_id}", status_code=303)
# Lazy-initialize sort_order if all zeros
all_zero = all(r["sort_order"] == 0 for r in rows)
if all_zero:
for i, r in enumerate(rows):
await db.execute(text("""
UPDATE folder_links SET sort_order = :so
WHERE folder_id = :fid AND link_id = :lid
"""), {"so": (i + 1) * 10, "fid": folder_id, "lid": r["link_id"]})
r["sort_order"] = (i + 1) * 10
# Find target index
idx = None
for i, r in enumerate(rows):
if str(r["link_id"]) == link_id:
idx = i
break
if idx is None:
return RedirectResponse(url=f"/weblinks?folder_id={folder_id}", status_code=303)
# Determine swap partner
if direction == "up" and idx > 0:
swap_idx = idx - 1
elif direction == "down" and idx < len(rows) - 1:
swap_idx = idx + 1
else:
return RedirectResponse(url=f"/weblinks?folder_id={folder_id}", status_code=303)
# Swap sort_order values
so_a, so_b = rows[idx]["sort_order"], rows[swap_idx]["sort_order"]
lid_a, lid_b = rows[idx]["link_id"], rows[swap_idx]["link_id"]
await db.execute(text("""
UPDATE folder_links SET sort_order = :so
WHERE folder_id = :fid AND link_id = :lid
"""), {"so": so_b, "fid": folder_id, "lid": lid_a})
await db.execute(text("""
UPDATE folder_links SET sort_order = :so
WHERE folder_id = :fid AND link_id = :lid
"""), {"so": so_a, "fid": folder_id, "lid": lid_b})
return RedirectResponse(url=f"/weblinks?folder_id={folder_id}", status_code=303)
# ---- Add existing link to folder ----
@router.post("/folders/{folder_id}/add-link")
async def add_link_to_folder(
folder_id: str,
link_id: str = Form(...),
db: AsyncSession = Depends(get_db),
):
# Remove link from any existing folder (single-folder membership)
await db.execute(text("DELETE FROM folder_links WHERE link_id = :lid"), {"lid": link_id})
# Get max sort_order in target folder
result = await db.execute(text("""
SELECT COALESCE(MAX(sort_order), 0) FROM folder_links WHERE folder_id = :fid
"""), {"fid": folder_id})
max_so = result.scalar()
# Insert into target folder at end
await db.execute(text("""
INSERT INTO folder_links (folder_id, link_id, sort_order)
VALUES (:fid, :lid, :so) ON CONFLICT DO NOTHING
"""), {"fid": folder_id, "lid": link_id, "so": max_so + 10})
return RedirectResponse(url=f"/weblinks?folder_id={folder_id}", status_code=303)
# ---- Folders ---- # ---- Folders ----
@router.get("/folders/create") @router.get("/folders/create")
async def create_folder_form(request: Request, db: AsyncSession = Depends(get_db)): async def create_folder_form(request: Request, db: AsyncSession = Depends(get_db)):
sidebar = await get_sidebar_data(db) sidebar = await get_sidebar_data(db)
result = await db.execute(text( result = await db.execute(text(
"SELECT id, name FROM weblink_folders WHERE is_deleted = false ORDER BY name" "SELECT id, name FROM link_folders WHERE is_deleted = false ORDER BY name"
)) ))
parent_folders = [dict(r._mapping) for r in result] parent_folders = [dict(r._mapping) for r in result]
return templates.TemplateResponse("weblink_folder_form.html", { return templates.TemplateResponse("weblink_folder_form.html", {
"request": request, "sidebar": sidebar, "request": request, "sidebar": sidebar,
"parent_folders": parent_folders, "parent_folders": parent_folders,
"page_title": "New Folder", "active_nav": "weblinks", "page_title": "New Folder", "active_nav": "bookmarks",
"item": None, "item": None,
}) })
@@ -218,7 +347,7 @@ async def create_folder(
parent_id: Optional[str] = Form(None), parent_id: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
repo = BaseRepository("weblink_folders", db) repo = BaseRepository("link_folders", db)
data = {"name": name} data = {"name": name}
if parent_id and parent_id.strip(): if parent_id and parent_id.strip():
data["parent_id"] = parent_id data["parent_id"] = parent_id
@@ -228,6 +357,13 @@ async def create_folder(
@router.post("/folders/{folder_id}/delete") @router.post("/folders/{folder_id}/delete")
async def delete_folder(folder_id: str, request: Request, db: AsyncSession = Depends(get_db)): async def delete_folder(folder_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("weblink_folders", db) # Prevent deleting the Default folder
result = await db.execute(text(
"SELECT name FROM link_folders WHERE id = :id"
), {"id": folder_id})
row = result.first()
if row and row[0] == "Default":
return RedirectResponse(url="/weblinks", status_code=303)
repo = BaseRepository("link_folders", db)
await repo.soft_delete(folder_id) await repo.soft_delete(folder_id)
return RedirectResponse(url="/weblinks", status_code=303) return RedirectResponse(url="/weblinks", status_code=303)

View File

@@ -170,13 +170,6 @@ function escHtml(s) {
} }
// ---- Mobile More Panel ----
function toggleMobileMore() {
const panel = document.getElementById('mobile-more-panel');
if (panel) panel.classList.toggle('open');
}
// ---- Timer Pill (topbar running timer) ---- // ---- Timer Pill (topbar running timer) ----
let timerStartAt = null; let timerStartAt = null;
@@ -241,3 +234,19 @@ document.addEventListener('DOMContentLoaded', () => {
pollTimer(); pollTimer();
setInterval(pollTimer, 30000); setInterval(pollTimer, 30000);
}); });
// ---- Mobile More Panel ----
document.addEventListener('DOMContentLoaded', function() {
var btn = document.getElementById('mobMoreBtn');
var panel = document.getElementById('mobMore');
var overlay = document.getElementById('mobOverlay');
if (!btn || !panel || !overlay) return;
btn.addEventListener('click', function() {
panel.classList.toggle('open');
overlay.classList.toggle('open');
});
overlay.addEventListener('click', function() {
panel.classList.remove('open');
overlay.classList.remove('open');
});
});

View File

@@ -369,6 +369,7 @@ a:hover { color: var(--accent-hover); }
.btn-sm { padding: 5px 10px; font-size: 0.8rem; } .btn-sm { padding: 5px 10px; font-size: 0.8rem; }
.btn-xs { padding: 3px 8px; font-size: 0.75rem; } .btn-xs { padding: 3px 8px; font-size: 0.75rem; }
.btn-capture { min-height: 44px; font-size: 0.95rem; padding: 10px 20px; }
/* ---- Cards ---- */ /* ---- Cards ---- */
.card { .card {
@@ -561,6 +562,7 @@ a:hover { color: var(--accent-hover); }
.form-input, .form-input,
.form-select, .form-select,
.form-textarea { .form-textarea {
width: 100%;
font-family: var(--font-body); font-family: var(--font-body);
font-size: 0.92rem; font-size: 0.92rem;
padding: 9px 12px; padding: 9px 12px;
@@ -748,6 +750,25 @@ a:hover { color: var(--accent-hover); }
gap: 20px; gap: 20px;
} }
.stat-card-link {
text-decoration: none;
color: inherit;
display: block;
}
.stat-card-link .stat-card {
transition: border-color 0.15s;
}
.stat-card-link:hover .stat-card {
border-color: var(--accent);
cursor: pointer;
}
.stat-card-link:hover {
color: inherit;
}
.stat-card { .stat-card {
background: var(--surface); background: var(--surface);
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -810,6 +831,19 @@ a:hover { color: var(--accent-hover); }
.focus-title { flex: 1; font-weight: 500; } .focus-title { flex: 1; font-weight: 500; }
.focus-meta { font-size: 0.78rem; color: var(--muted); } .focus-meta { font-size: 0.78rem; color: var(--muted); }
/* ---- Alerts ---- */
.alert {
padding: 12px 16px;
border-radius: var(--radius);
font-size: 0.88rem;
font-weight: 500;
}
.alert-warning {
background: var(--amber-soft);
color: var(--amber);
border: 1px solid var(--amber);
}
/* ---- Utility ---- */ /* ---- Utility ---- */
.text-muted { color: var(--muted); } .text-muted { color: var(--muted); }
.text-sm { font-size: 0.82rem; } .text-sm { font-size: 0.82rem; }
@@ -857,6 +891,51 @@ a:hover { color: var(--accent-hover); }
color: var(--muted); color: var(--muted);
} }
/* ---- Quick Capture (Topbar) ---- */
.quick-capture-form {
display: flex;
align-items: center;
position: relative;
}
.quick-capture-icon {
position: absolute;
left: 10px;
color: var(--accent);
font-size: 0.88rem;
font-weight: 700;
pointer-events: none;
line-height: 1;
}
.quick-capture-input {
width: 250px;
font-family: var(--font-body);
font-size: 0.82rem;
padding: 6px 12px 6px 26px;
background: var(--surface2);
color: var(--text);
border: 1px solid var(--border);
border-left: 2px solid var(--accent);
border-radius: var(--radius);
transition: border-color var(--transition);
}
.quick-capture-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
border-left-color: var(--accent);
}
.quick-capture-input::placeholder {
color: var(--muted);
}
.quick-capture-submit {
display: none;
}
/* ---- Search Modal ---- */ /* ---- Search Modal ---- */
.search-modal { .search-modal {
position: fixed; position: fixed;
@@ -1013,136 +1092,127 @@ a:hover { color: var(--accent-hover); }
.weblink-folder-item.active { background: var(--accent-soft); color: var(--accent); font-weight: 500; } .weblink-folder-item.active { background: var(--accent-soft); color: var(--accent); font-weight: 500; }
.weblinks-content { flex: 1; min-width: 0; } .weblinks-content { flex: 1; min-width: 0; }
/* ---- Project Progress Mini Bar (dashboard) ---- */
.project-progress-mini {
width: 60px;
height: 6px;
background: var(--surface2);
border-radius: 3px;
overflow: hidden;
flex-shrink: 0;
}
.project-progress-bar {
height: 100%;
background: var(--green);
border-radius: 3px;
transition: width 0.3s;
}
/* ---- Scrollbar ---- */ /* ---- Scrollbar ---- */
::-webkit-scrollbar { width: 6px; } ::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--muted); } ::-webkit-scrollbar-thumb:hover { background: var(--muted); }
/* ---- Mobile Bottom Nav ---- */
.mobile-bottom-bar {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 56px;
background: var(--surface);
border-top: 1px solid var(--border);
z-index: 200;
align-items: center;
justify-content: space-around;
padding: 0 4px;
padding-bottom: env(safe-area-inset-bottom, 0);
}
.mobile-nav-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
flex: 1;
padding: 6px 0;
color: var(--muted);
text-decoration: none;
background: none;
border: none;
font-family: var(--font-body);
cursor: pointer;
transition: color var(--transition);
-webkit-tap-highlight-color: transparent;
}
.mobile-nav-item svg {
width: 24px;
height: 24px;
}
.mobile-nav-item span {
font-size: 10px;
font-weight: 500;
line-height: 1;
}
.mobile-nav-item.active {
color: var(--accent);
}
/* Mobile More Panel */
.mobile-more-panel {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 199;
}
.mobile-more-panel.open {
display: block;
}
.mobile-more-backdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
}
.mobile-more-content {
position: absolute;
bottom: 56px;
left: 0;
right: 0;
background: var(--surface);
border-top: 1px solid var(--border);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
padding: 8px 0;
transform: translateY(100%);
transition: transform 200ms ease;
padding-bottom: env(safe-area-inset-bottom, 0);
}
.mobile-more-panel.open .mobile-more-content {
transform: translateY(0);
}
.mobile-more-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
color: var(--text);
text-decoration: none;
font-size: 0.92rem;
font-weight: 500;
transition: background var(--transition);
}
.mobile-more-item:hover {
background: var(--surface2);
color: var(--text);
}
.mobile-more-item svg {
width: 20px;
height: 20px;
color: var(--muted);
flex-shrink: 0;
}
/* ---- Responsive ---- */ /* ---- Responsive ---- */
@media (max-width: 768px) { @media (max-width: 768px) {
body { padding-bottom: 60px; }
.sidebar { display: none; } .sidebar { display: none; }
.main-content { margin-left: 0; } .main-content { margin-left: 0; }
.form-grid { grid-template-columns: 1fr; } .form-grid { grid-template-columns: 1fr; }
.dashboard-grid { grid-template-columns: 1fr; } .dashboard-grid { grid-template-columns: 1fr; }
.page-content { padding: 16px; padding-bottom: 72px; } .page-content { padding: 16px; padding-bottom: 72px; }
.mobile-bottom-bar { display: flex; } /* Topbar: stack vertically on mobile */
.topbar {
height: auto;
min-height: auto;
flex-direction: column;
align-items: stretch;
padding: 8px 12px;
gap: 8px;
}
.topbar-env {
align-self: flex-start;
}
.topbar-spacer {
display: none;
}
.quick-capture-form {
order: unset;
width: 100%;
flex: unset;
display: flex;
align-items: center;
gap: 8px;
padding: 0;
}
.quick-capture-icon {
display: none;
}
.quick-capture-input {
flex: 1;
width: auto;
height: 36px;
min-height: auto;
background: var(--surface2);
border: 1px solid var(--border);
border-left: 1px solid var(--border);
border-radius: 4px;
padding: 0 10px;
font-size: 16px;
}
.quick-capture-submit {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: var(--accent);
color: #fff;
border: none;
border-radius: 4px;
font-size: 1.2rem;
font-weight: 700;
cursor: pointer;
flex-shrink: 0;
margin-left: 0;
}
.quick-capture-submit:hover {
background: var(--accent-hover);
}
.search-trigger {
width: 100%;
min-width: unset;
}
/* Capture form mobile */
.capture-form-card { padding: 12px; }
.capture-form-card .form-textarea {
font-size: 16px; /* prevents iOS zoom */
min-height: 80px;
}
.capture-form-card .btn-capture {
width: 100%;
min-height: 44px;
font-size: 1rem;
}
/* Capture items stack on mobile */
.capture-item {
flex-direction: column;
gap: 8px;
}
.capture-actions {
flex-wrap: wrap;
}
} }
.search-type-appointments { background: var(--amber-soft); color: var(--amber); } .search-type-appointments { background: var(--amber-soft); color: var(--amber); }
@@ -1223,3 +1293,441 @@ a:hover { color: var(--accent-hover); }
.timer-detail-stop:hover { .timer-detail-stop:hover {
background: var(--red, #ef4444)33; background: var(--red, #ef4444)33;
} }
/* ---- Calendar View ---- */
.cal-nav {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.cal-month-label {
font-size: 1.15rem;
font-weight: 700;
min-width: 180px;
text-align: center;
}
.cal-legend {
display: flex;
gap: 16px;
margin-bottom: 12px;
font-size: 0.78rem;
color: var(--muted);
}
.cal-legend-item {
display: flex;
align-items: center;
gap: 4px;
}
.cal-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.cal-dot-appointment { background: var(--amber); }
.cal-dot-meeting { background: var(--purple); }
.cal-dot-task { background: var(--accent); }
.cal-grid {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.cal-header-row {
display: grid;
grid-template-columns: repeat(7, 1fr);
border-bottom: 1px solid var(--border);
}
.cal-header-cell {
padding: 8px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
text-align: center;
}
.cal-week-row {
display: grid;
grid-template-columns: repeat(7, 1fr);
min-height: 100px;
}
.cal-week-row + .cal-week-row {
border-top: 1px solid var(--border);
}
.cal-day-cell {
padding: 4px;
border-right: 1px solid var(--border);
min-height: 100px;
display: flex;
flex-direction: column;
}
.cal-day-cell:last-child {
border-right: none;
}
.cal-day-empty {
background: var(--surface2);
min-height: 100px;
}
.cal-day-today {
background: var(--accent-soft);
}
.cal-day-num {
font-size: 0.78rem;
font-weight: 600;
color: var(--text-secondary);
padding: 2px 4px;
text-align: right;
}
.cal-day-today .cal-day-num {
color: var(--accent);
font-weight: 700;
}
.cal-events {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
overflow: hidden;
}
.cal-event {
display: block;
padding: 2px 4px;
border-radius: var(--radius-sm);
font-size: 0.72rem;
line-height: 1.3;
text-decoration: none;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
transition: opacity var(--transition);
}
.cal-event:hover {
opacity: 0.8;
}
.cal-event-appointment {
background: var(--amber-soft);
color: var(--amber);
border-left: 2px solid var(--amber);
}
.cal-event-meeting {
background: var(--purple-soft);
color: var(--purple);
border-left: 2px solid var(--purple);
}
.cal-event-task {
background: var(--accent-soft);
color: var(--accent);
border-left: 2px solid var(--accent);
}
.cal-event-time {
font-weight: 600;
margin-right: 2px;
}
.cal-event-title {
font-weight: 500;
}
@media (max-width: 768px) {
.cal-week-row { min-height: 60px; }
.cal-day-cell { min-height: 60px; padding: 2px; }
.cal-day-empty { min-height: 60px; }
.cal-day-num { font-size: 0.72rem; }
.cal-event { font-size: 0.65rem; padding: 1px 2px; }
.cal-event-time { display: none; }
.cal-month-label { font-size: 1rem; min-width: 140px; }
}
/* ---- Eisenhower Matrix ---- */
.eisenhower-grid {
display: grid;
grid-template-columns: 32px 1fr 1fr;
grid-template-rows: auto auto auto;
gap: 12px;
}
.eisenhower-y-label {
display: flex;
align-items: center;
justify-content: center;
writing-mode: vertical-lr;
transform: rotate(180deg);
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.eisenhower-x-spacer {
/* empty cell under the y-labels */
}
.eisenhower-x-label {
text-align: center;
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
padding: 4px 0;
}
.eisenhower-quadrant {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 16px;
min-height: 200px;
display: flex;
flex-direction: column;
}
.eisenhower-q1 { border-top: 3px solid var(--red); }
.eisenhower-q2 { border-top: 3px solid var(--accent); }
.eisenhower-q3 { border-top: 3px solid var(--amber); }
.eisenhower-q4 { border-top: 3px solid var(--muted); }
.eisenhower-quadrant-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.eisenhower-quadrant-header h3 {
margin: 0;
font-size: 0.95rem;
font-weight: 700;
}
.eisenhower-quadrant-subtitle {
font-size: 0.72rem;
color: var(--muted);
}
.eisenhower-task-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 2px;
}
.eisenhower-task {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: var(--radius);
text-decoration: none;
color: var(--text);
font-size: 0.82rem;
transition: background var(--transition);
}
.eisenhower-task:hover {
background: var(--surface2);
color: var(--text);
}
.eisenhower-task-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.eisenhower-task-due {
font-size: 0.72rem;
color: var(--muted);
white-space: nowrap;
}
.eisenhower-task-due.overdue {
color: var(--red);
font-weight: 600;
}
.eisenhower-task-project {
font-size: 0.68rem;
color: var(--muted);
background: var(--surface2);
padding: 1px 6px;
border-radius: var(--radius-sm);
white-space: nowrap;
}
.eisenhower-empty {
color: var(--muted);
font-size: 0.82rem;
padding: 12px;
text-align: center;
}
.badge-red { background: var(--red); color: #fff; }
.badge-amber { background: var(--amber); color: #fff; }
.badge-accent { background: var(--accent); color: #fff; }
.badge-muted { background: var(--muted); color: #fff; }
@media (max-width: 768px) {
.eisenhower-grid {
grid-template-columns: 1fr;
grid-template-rows: auto;
min-height: auto;
}
.eisenhower-y-label,
.eisenhower-x-label,
.eisenhower-x-spacer { display: none; }
.eisenhower-quadrant { min-height: 120px; }
}
/* === MOBILE NAV BAR === */
.mob-nav {
display: none !important;
}
@media (max-width: 768px) {
.mob-nav {
display: flex !important;
position: fixed !important;
bottom: 0 !important;
left: 0 !important;
right: 0 !important;
height: 56px !important;
background: var(--surface) !important;
border-top: 1px solid var(--border) !important;
z-index: 9999 !important;
flex-direction: row !important;
flex-wrap: nowrap !important;
align-items: center !important;
justify-content: space-around !important;
padding: 0 !important;
margin: 0 !important;
overflow: hidden !important;
}
.mob-nav__item {
display: flex !important;
flex-direction: column !important;
align-items: center !important;
justify-content: center !important;
flex: 1 1 0 !important;
min-width: 0 !important;
max-width: 20% !important;
height: 56px !important;
padding: 6px 0 !important;
margin: 0 !important;
color: var(--muted) !important;
text-decoration: none !important;
background: none !important;
border: none !important;
font-family: inherit !important;
font-size: 10px !important;
font-weight: 500 !important;
line-height: 1 !important;
cursor: pointer !important;
-webkit-tap-highlight-color: transparent !important;
gap: 2px !important;
}
.mob-nav__item svg {
width: 22px !important;
height: 22px !important;
flex-shrink: 0 !important;
}
.mob-nav__item span {
font-size: 10px !important;
line-height: 1 !important;
}
.mob-nav__item--active {
color: var(--accent) !important;
}
.mob-overlay {
display: none !important;
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
background: rgba(0,0,0,0.5) !important;
z-index: 10000 !important;
}
.mob-overlay.open {
display: block !important;
}
.mob-more {
display: none !important;
position: fixed !important;
bottom: 56px !important;
left: 0 !important;
right: 0 !important;
background: var(--surface) !important;
border-top: 1px solid var(--border) !important;
border-radius: 12px 12px 0 0 !important;
z-index: 10001 !important;
padding: 16px !important;
grid-template-columns: repeat(3, 1fr) !important;
gap: 8px !important;
}
.mob-more.open {
display: grid !important;
}
.mob-more__item {
display: flex !important;
flex-direction: column !important;
align-items: center !important;
justify-content: center !important;
gap: 6px !important;
padding: 12px 4px !important;
color: var(--text) !important;
text-decoration: none !important;
font-size: 0.78rem !important;
font-weight: 500 !important;
border-radius: 8px !important;
}
.mob-more__item:hover {
background: var(--surface2) !important;
}
.mob-more__item svg {
width: 22px !important;
height: 22px !important;
color: var(--muted) !important;
}
}

View File

@@ -5,6 +5,17 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ page_title or "Life OS" }} - Life OS</title> <title>{{ page_title or "Life OS" }} - Life OS</title>
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
<style>
/* Critical mob-nav positioning - inline to guarantee it loads */
.mob-nav{display:none}
@media(max-width:768px){
.mob-nav{display:flex!important;position:fixed!important;bottom:0!important;left:0!important;right:0!important;height:56px!important;background:var(--surface)!important;border-top:1px solid var(--border)!important;z-index:9999!important;flex-direction:row!important;flex-wrap:nowrap!important;align-items:center!important;justify-content:space-around!important;padding:0!important;margin:0!important;overflow:hidden!important}
.mob-overlay{display:none!important;position:fixed!important;top:0!important;left:0!important;right:0!important;bottom:0!important;background:rgba(0,0,0,.5)!important;z-index:10000!important}
.mob-overlay.open{display:block!important}
.mob-more{display:none!important;position:fixed!important;bottom:56px!important;left:0!important;right:0!important;background:var(--surface)!important;border-top:1px solid var(--border)!important;border-radius:12px 12px 0 0!important;z-index:10001!important;padding:16px!important;grid-template-columns:repeat(3,1fr)!important;gap:8px!important}
.mob-more.open{display:grid!important}
}
</style>
</head> </head>
<body> <body>
<div class="app-layout"> <div class="app-layout">
@@ -48,6 +59,10 @@
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg> <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
Lists Lists
</a> </a>
<a href="/processes" class="nav-item {{ 'active' if active_nav == 'processes' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
Processes
</a>
<a href="/meetings" class="nav-item {{ 'active' if active_nav == 'meetings' }}"> <a href="/meetings" class="nav-item {{ 'active' if active_nav == 'meetings' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg> <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
Meetings Meetings
@@ -56,6 +71,14 @@
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
Appointments Appointments
</a> </a>
<a href="/calendar" class="nav-item {{ 'active' if active_nav == 'calendar' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><rect x="7" y="14" width="3" height="3" rx="0.5"/><rect x="14" y="14" width="3" height="3" rx="0.5"/></svg>
Calendar
</a>
<a href="/eisenhower" class="nav-item {{ 'active' if active_nav == 'eisenhower' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="8" height="8" rx="1"/><rect x="13" y="3" width="8" height="8" rx="1"/><rect x="3" y="13" width="8" height="8" rx="1"/><rect x="13" y="13" width="8" height="8" rx="1"/></svg>
Eisenhower
</a>
<a href="/decisions" class="nav-item {{ 'active' if active_nav == 'decisions' }}"> <a href="/decisions" class="nav-item {{ 'active' if active_nav == 'decisions' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"/><path d="M9 12l2 2 4-4"/></svg> <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"/><path d="M9 12l2 2 4-4"/></svg>
Decisions Decisions
@@ -64,14 +87,18 @@
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg> <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>
Files Files
</a> </a>
<a href="/weblinks" class="nav-item {{ 'active' if active_nav == 'weblinks' }}"> <a href="/weblinks" class="nav-item {{ 'active' if active_nav == 'bookmarks' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg> <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Weblinks Bookmarks
</a> </a>
<a href="/time" class="nav-item {{ 'active' if active_nav == 'time' }}"> <a href="/time" class="nav-item {{ 'active' if active_nav == 'time' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
Time Log Time Log
</a> </a>
<a href="/time-budgets" class="nav-item {{ 'active' if active_nav == 'time_budgets' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/><path d="M16 3l2 2-2 2"/></svg>
Time Budgets
</a>
<a href="/capture" class="nav-item {{ 'active' if active_nav == 'capture' }}"> <a href="/capture" class="nav-item {{ 'active' if active_nav == 'capture' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg> <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>
Capture Capture
@@ -104,6 +131,10 @@
</div> </div>
<div class="nav-section" style="margin-top: auto; padding-bottom: 12px;"> <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' }}"> <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> <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 Trash
@@ -140,6 +171,12 @@
<button type="submit" class="timer-pill-stop" title="Stop timer">&#9632;</button> <button type="submit" class="timer-pill-stop" title="Stop timer">&#9632;</button>
</form> </form>
</div> </div>
<form class="quick-capture-form" method="POST" action="/capture/add">
<input type="hidden" name="redirect_to" value="{{ request.url.path }}{% if request.url.query %}?{{ request.url.query }}{% endif %}">
<span class="quick-capture-icon">+</span>
<input type="text" name="raw_text" class="quick-capture-input" placeholder="Quick capture..." autocomplete="off" required>
<button type="submit" class="quick-capture-submit" title="Capture">+</button>
</form>
<button class="search-trigger" onclick="openSearch()" title="Search (Cmd/K)"> <button class="search-trigger" onclick="openSearch()" title="Search (Cmd/K)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<span>Search...</span> <span>Search...</span>
@@ -165,61 +202,40 @@
</div> </div>
</div> </div>
<!-- Mobile Bottom Nav --> <script src="/static/app.js"></script>
<nav class="mobile-bottom-bar">
<a href="/" class="mobile-nav-item {% if active_nav == 'dashboard' %}active{% endif %}"> <div class="mob-nav" id="mobNav" style="position:fixed;bottom:0;left:0;right:0;z-index:9999">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg> <a href="/" class="mob-nav__item {% if request.url.path == '/' %}mob-nav__item--active{% endif %}">
<span>Dashboard</span> <svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/></svg>
<span>Home</span>
</a> </a>
<a href="/focus" class="mobile-nav-item {% if active_nav == 'focus' %}active{% endif %}"> <a href="/focus" class="mob-nav__item {% if '/focus' in request.url.path %}mob-nav__item--active{% endif %}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>
<span>Focus</span> <span>Focus</span>
</a> </a>
<a href="/tasks" class="mobile-nav-item {% if active_nav == 'tasks' %}active{% endif %}"> <a href="/tasks" class="mob-nav__item {% if '/tasks' in request.url.path %}mob-nav__item--active{% endif %}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>
<span>Tasks</span> <span>Tasks</span>
</a> </a>
<a href="/appointments" class="mobile-nav-item {% if active_nav == 'appointments' %}active{% endif %}"> <a href="/capture" class="mob-nav__item {% if '/capture' in request.url.path %}mob-nav__item--active{% endif %}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
<span>Calendar</span> <span>Capture</span>
</a> </a>
<button class="mobile-nav-item" id="mobile-more-btn" onclick="toggleMobileMore()"> <button class="mob-nav__item" id="mobMoreBtn" type="button">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
<span>More</span> <span>More</span>
</button> </button>
</nav>
<!-- Mobile More Panel -->
<div id="mobile-more-panel" class="mobile-more-panel">
<div class="mobile-more-backdrop" onclick="toggleMobileMore()"></div>
<div class="mobile-more-content">
<a href="/notes" class="mobile-more-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>
Notes
</a>
<a href="/meetings" class="mobile-more-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
Meetings
</a>
<a href="/decisions" class="mobile-more-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"/><path d="M9 12l2 2 4-4"/></svg>
Decisions
</a>
<a href="/contacts" class="mobile-more-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
Contacts
</a>
<a href="/weblinks" class="mobile-more-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Weblinks
</a>
<a href="/admin/trash" class="mobile-more-item">
<svg 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>
Admin
</a>
</div>
</div> </div>
<div class="mob-overlay" id="mobOverlay"></div>
<script src="/static/app.js"></script> <div class="mob-more" id="mobMore">
<a href="/calendar" class="mob-more__item"><span>Calendar</span></a>
<a href="/notes" class="mob-more__item"><span>Notes</span></a>
<a href="/meetings" class="mob-more__item"><span>Meetings</span></a>
<a href="/decisions" class="mob-more__item"><span>Decisions</span></a>
<a href="/contacts" class="mob-more__item"><span>Contacts</span></a>
<a href="/processes" class="mob-more__item"><span>Processes</span></a>
<a href="/weblinks" class="mob-more__item"><span>Bookmarks</span></a>
<a href="/admin" class="mob-more__item"><span>Admin</span></a>
</div>
</body> </body>
</html> </html>

58
templates/calendar.html Normal file
View File

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

View File

@@ -5,10 +5,10 @@
</div> </div>
<!-- Quick capture input --> <!-- Quick capture input -->
<div class="card mb-4"> <div class="card mb-4 capture-form-card">
<form action="/capture/add" method="post"> <form action="/capture/add" method="post">
<label class="form-label mb-2">Quick Capture (one item per line)</label> <label class="form-label mb-2">Quick Capture (one item per line)</label>
<textarea name="raw_text" class="form-textarea" rows="3" placeholder="Type or paste items here...&#10;Each line becomes a separate capture item" required></textarea> <textarea name="raw_text" class="form-textarea" rows="3" placeholder="Type or paste to capture..." required></textarea>
<details class="mt-2"> <details class="mt-2">
<summary class="text-sm text-muted" style="cursor:pointer">Context (optional)</summary> <summary class="text-sm text-muted" style="cursor:pointer">Context (optional)</summary>
<div class="form-grid mt-2" style="grid-template-columns:1fr 1fr"> <div class="form-grid mt-2" style="grid-template-columns:1fr 1fr">
@@ -41,7 +41,7 @@
</div> </div>
</div> </div>
</details> </details>
<div class="mt-2"><button type="submit" class="btn btn-primary">Capture</button></div> <div class="mt-2"><button type="submit" class="btn btn-primary btn-capture">Capture</button></div>
</form> </form>
</div> </div>
@@ -75,8 +75,8 @@
{% if item.converted_to_id %} {% if item.converted_to_id %}
{% if item.converted_to_type == 'list_item' and item.list_id %} {% if item.converted_to_type == 'list_item' and item.list_id %}
<a href="/lists/{{ item.list_id }}" class="btn btn-ghost btn-xs">View &rarr;</a> <a href="/lists/{{ item.list_id }}" class="btn btn-ghost btn-xs">View &rarr;</a>
{% elif item.converted_to_type == 'weblink' %} {% elif item.converted_to_type == 'link' %}
<a href="/weblinks" class="btn btn-ghost btn-xs">View &rarr;</a> <a href="/links" class="btn btn-ghost btn-xs">View &rarr;</a>
{% elif item.converted_to_type in ('task', 'note', 'project', 'contact', 'decision') %} {% elif item.converted_to_type in ('task', 'note', 'project', 'contact', 'decision') %}
<a href="/{{ item.converted_to_type }}s/{{ item.converted_to_id }}" class="btn btn-ghost btn-xs">View &rarr;</a> <a href="/{{ item.converted_to_type }}s/{{ item.converted_to_id }}" class="btn btn-ghost btn-xs">View &rarr;</a>
{% endif %} {% endif %}

View File

@@ -132,7 +132,7 @@
<input type="text" name="title" class="form-input" value="{{ item.raw_text }}" required> <input type="text" name="title" class="form-input" value="{{ item.raw_text }}" required>
</div> </div>
{% elif convert_type == 'weblink' %} {% elif convert_type == 'link' %}
<div class="form-group full-width"> <div class="form-group full-width">
<label class="form-label">Label</label> <label class="form-label">Label</label>
<input type="text" name="label" class="form-input" value="{{ prefill_label }}" required> <input type="text" name="label" class="form-input" value="{{ prefill_label }}" required>

View File

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

View File

@@ -77,9 +77,10 @@
</div> </div>
</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"> <div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'Save Changes' if item else 'Record Decision' }}</button> <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> </div>
</form> </form>
</div> </div>

184
templates/eisenhower.html Normal file
View File

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

View File

@@ -3,6 +3,10 @@
<div class="breadcrumb"> <div class="breadcrumb">
<a href="/files">Files</a> <a href="/files">Files</a>
<span class="sep">/</span> <span class="sep">/</span>
{% if folder and folder != '/' %}
<a href="/files?folder={{ folder }}">{{ folder }}</a>
<span class="sep">/</span>
{% endif %}
<span>{{ item.original_filename }}</span> <span>{{ item.original_filename }}</span>
</div> </div>
@@ -17,6 +21,7 @@
</div> </div>
<div class="detail-meta mt-2"> <div class="detail-meta mt-2">
<span class="detail-meta-item">Folder: {{ folder }}</span>
{% if item.mime_type %}<span class="detail-meta-item">Type: {{ item.mime_type }}</span>{% endif %} {% if item.mime_type %}<span class="detail-meta-item">Type: {{ item.mime_type }}</span>{% endif %}
{% if item.size_bytes %}<span class="detail-meta-item">Size: {{ "%.1f"|format(item.size_bytes / 1024) }} KB</span>{% endif %} {% if item.size_bytes %}<span class="detail-meta-item">Size: {{ "%.1f"|format(item.size_bytes / 1024) }} KB</span>{% endif %}
{% if item.description %}<span class="detail-meta-item">{{ item.description }}</span>{% endif %} {% if item.description %}<span class="detail-meta-item">{{ item.description }}</span>{% endif %}
@@ -34,7 +39,7 @@
{% elif item.mime_type == 'application/pdf' %} {% elif item.mime_type == 'application/pdf' %}
<iframe src="/files/{{ item.id }}/serve" style="width: 100%; height: 600px; border: none; border-radius: var(--radius);"></iframe> <iframe src="/files/{{ item.id }}/serve" style="width: 100%; height: 600px; border: none; border-radius: var(--radius);"></iframe>
{% elif item.mime_type and item.mime_type.startswith('text/') %} {% elif item.mime_type and item.mime_type.startswith('text/') %}
<iframe src="/files/{{ item.id }}/serve" style="width: 100%; height: 400px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface2);"></iframe> <iframe src="/files/{{ item.id }}/serve" style="width: 100%; height: 400px; border: 1px solid var(--border); border-radius: var(--radius); background: #fff;"></iframe>
{% endif %} {% endif %}
</div> </div>
{% else %} {% else %}

View File

@@ -17,6 +17,21 @@
<input type="file" name="file" class="form-input" required> <input type="file" name="file" class="form-input" required>
</div> </div>
<div class="form-group">
<label class="form-label">Folder</label>
<select name="folder" class="form-input">
<option value="">/ (root)</option>
{% for f in folders %}
<option value="{{ f }}" {{ 'selected' if prefill_folder == f }}>{{ f }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label class="form-label">New Folder</label>
<input type="text" name="new_folder" class="form-input" placeholder="Or create new folder...">
</div>
<div class="form-group full-width"> <div class="form-group full-width">
<label class="form-label">Description</label> <label class="form-label">Description</label>
<input type="text" name="description" class="form-input" placeholder="Optional description..."> <input type="text" name="description" class="form-input" placeholder="Optional description...">

View File

@@ -2,9 +2,30 @@
{% block content %} {% block content %}
<div class="page-header"> <div class="page-header">
<h1 class="page-title">Files<span class="page-count">{{ items|length }}</span></h1> <h1 class="page-title">Files<span class="page-count">{{ items|length }}</span></h1>
<a href="/files/upload{{ '?context_type=' ~ context_type ~ '&context_id=' ~ context_id if context_type }}" class="btn btn-primary">+ Upload File</a> <div class="flex gap-2">
<form action="/files/sync" method="post" style="display:inline">
<button type="submit" class="btn btn-secondary">Sync Files</button>
</form>
<a href="/files/upload{{ '?folder=' ~ current_folder if current_folder }}{{ '?context_type=' ~ context_type ~ '&context_id=' ~ context_id if context_type }}" class="btn btn-primary">+ Upload File</a>
</div>
</div> </div>
{% if sync_result and (sync_result.added > 0 or sync_result.removed > 0) %}
<div class="flash-message" style="background: var(--accent-soft); border: 1px solid var(--accent); border-radius: var(--radius); padding: 8px 12px; margin-bottom: 16px; color: var(--text); font-size: 0.85rem;">
Synced: {{ sync_result.added }} file{{ 's' if sync_result.added != 1 }} added, {{ sync_result.removed }} removed
</div>
{% endif %}
{% if folders %}
<div class="filter-bar" style="display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px;">
<a href="/files" class="btn btn-xs {{ 'btn-primary' if current_folder is none else 'btn-ghost' }}">All</a>
<a href="/files?folder=" class="btn btn-xs {{ 'btn-primary' if current_folder is not none and current_folder == '' else 'btn-ghost' }}">/</a>
{% for f in folders %}
<a href="/files?folder={{ f }}" class="btn btn-xs {{ 'btn-primary' if current_folder == f else 'btn-ghost' }}">{{ f }}</a>
{% endfor %}
</div>
{% endif %}
{% if items %} {% if items %}
<div class="card"> <div class="card">
{% for item in items %} {% for item in items %}
@@ -12,6 +33,7 @@
<span class="row-title"> <span class="row-title">
<a href="/files/{{ item.id }}/preview">{{ item.original_filename }}</a> <a href="/files/{{ item.id }}/preview">{{ item.original_filename }}</a>
</span> </span>
<span class="row-meta" style="color: var(--muted); font-size: 0.8rem;">{{ item.folder }}</span>
{% if item.mime_type %} {% if item.mime_type %}
<span class="row-tag">{{ item.mime_type.split('/')|last }}</span> <span class="row-tag">{{ item.mime_type.split('/')|last }}</span>
{% endif %} {% endif %}
@@ -33,7 +55,7 @@
{% else %} {% else %}
<div class="empty-state"> <div class="empty-state">
<div class="empty-state-icon">&#128193;</div> <div class="empty-state-icon">&#128193;</div>
<div class="empty-state-text">No files uploaded yet</div> <div class="empty-state-text">No files{{ ' in this folder' if current_folder is not none else ' uploaded yet' }}</div>
<a href="/files/upload" class="btn btn-primary">Upload First File</a> <a href="/files/upload" class="btn btn-primary">Upload First File</a>
</div> </div>
{% endif %} {% endif %}

View File

@@ -37,9 +37,31 @@
{% endif %} {% endif %}
<!-- Add task to focus --> <!-- Add task to focus -->
{% if available_tasks %}
<div class="card mt-4"> <div class="card mt-4">
<div class="card-header"><h2 class="card-title">Add to Focus</h2></div> <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] %} {% for t in available_tasks[:15] %}
<div class="list-row"> <div class="list-row">
<span class="priority-dot priority-{{ t.priority }}"></span> <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> <button class="btn btn-ghost btn-xs" style="color:var(--green)">+ Focus</button>
</form> </form>
</div> </div>
{% else %}
<div style="padding: 16px; color: var(--muted); font-size: 0.85rem;">No available tasks matching filters</div>
{% endfor %} {% endfor %}
</div> </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 %} {% 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

@@ -7,11 +7,14 @@
<div class="form-grid"> <div class="form-grid">
<div class="form-group"><label class="form-label">Label *</label><input type="text" name="label" class="form-input" required value="{{ item.label if item else '' }}"></div> <div class="form-group"><label class="form-label">Label *</label><input type="text" name="label" class="form-input" required value="{{ item.label if item else '' }}"></div>
<div class="form-group"><label class="form-label">URL *</label><input type="url" name="url" class="form-input" required value="{{ item.url if item else '' }}"></div> <div class="form-group"><label class="form-label">URL *</label><input type="url" name="url" class="form-input" required value="{{ item.url if item else '' }}"></div>
<div class="form-group"><label class="form-label">Domain *</label> <div class="form-group"><label class="form-label">Domain</label>
<select name="domain_id" class="form-select" required>{% for d in domains %}<option value="{{ d.id }}" {{ 'selected' if (item and item.domain_id|string == d.id|string) or (not item and prefill_domain_id == d.id|string) }}>{{ d.name }}</option>{% endfor %}</select></div> <select name="domain_id" class="form-select"><option value="">-- None --</option>{% for d in domains %}<option value="{{ d.id }}" {{ 'selected' if (item and item.domain_id and item.domain_id|string == d.id|string) or (not item and prefill_domain_id == d.id|string) }}>{{ d.name }}</option>{% endfor %}</select></div>
<div class="form-group"><label class="form-label">Project</label> <div class="form-group"><label class="form-label">Project</label>
<select name="project_id" class="form-select"><option value="">-- None --</option>{% for p in projects %}<option value="{{ p.id }}" {{ 'selected' if (item and item.project_id and item.project_id|string == p.id|string) or (not item and prefill_project_id == p.id|string) }}>{{ p.name }}</option>{% endfor %}</select></div> <select name="project_id" class="form-select"><option value="">-- None --</option>{% for p in projects %}<option value="{{ p.id }}" {{ 'selected' if (item and item.project_id and item.project_id|string == p.id|string) or (not item and prefill_project_id == p.id|string) }}>{{ p.name }}</option>{% endfor %}</select></div>
<div class="form-group full-width"><label class="form-label">Tags</label><input type="text" name="tags" class="form-input" placeholder="tag1, tag2, ..." value="{{ item.tags|join(', ') if item and item.tags else '' }}"></div>
<div class="form-group full-width"><label class="form-label">Description</label><textarea name="description" class="form-textarea" rows="3">{{ item.description if item and item.description else '' }}</textarea></div> <div class="form-group full-width"><label class="form-label">Description</label><textarea name="description" class="form-textarea" rows="3">{{ item.description if item and item.description else '' }}</textarea></div>
{% if prefill_task_id is defined and prefill_task_id %}<input type="hidden" name="task_id" value="{{ prefill_task_id }}">{% endif %}
{% if prefill_meeting_id is defined and prefill_meeting_id %}<input type="hidden" name="meeting_id" value="{{ prefill_meeting_id }}">{% endif %}
<div class="form-actions"><button type="submit" class="btn btn-primary">{{ 'Save' if item else 'Create' }}</button><a href="/links" class="btn btn-secondary">Cancel</a></div> <div class="form-actions"><button type="submit" class="btn btn-primary">{{ 'Save' if item else 'Create' }}</button><a href="/links" class="btn btn-secondary">Cancel</a></div>
</div> </div>
</form></div> </form></div>

View File

@@ -95,4 +95,38 @@
<div class="empty-state-text">No items yet. Add one above.</div> <div class="empty-state-text">No items yet. Add one above.</div>
</div> </div>
{% endif %} {% 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 %} {% endblock %}

View File

@@ -73,9 +73,11 @@
</div> </div>
</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"> <div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'Save Changes' if item else 'Create List' }}</button> <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> </div>
</form> </form>
</div> </div>

View File

@@ -6,14 +6,14 @@
</div> </div>
<!-- Filters --> <!-- Filters -->
<form class="filters-bar" method="get" action="/lists"> <form class="filters-bar" method="get" action="/lists" id="list-filters">
<select name="domain_id" class="filter-select" onchange="this.form.submit()"> <select name="domain_id" class="filter-select" id="domain-filter" onchange="this.form.submit()">
<option value="">All Domains</option> <option value="">All Domains</option>
{% for d in domains %} {% for d in domains %}
<option value="{{ d.id }}" {{ 'selected' if current_domain_id == d.id|string }}>{{ d.name }}</option> <option value="{{ d.id }}" {{ 'selected' if current_domain_id == d.id|string }}>{{ d.name }}</option>
{% endfor %} {% endfor %}
</select> </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> <option value="">All Projects</option>
{% for p in projects %} {% for p in projects %}
<option value="{{ p.id }}" {{ 'selected' if current_project_id == p.id|string }}>{{ p.name }}</option> <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> <a href="/lists/create" class="btn btn-primary">Create First List</a>
</div> </div>
{% endif %} {% 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 %} {% endblock %}

View File

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

View File

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

View File

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

52
templates/processes.html Normal file
View File

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

View File

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

View File

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

View File

@@ -21,10 +21,10 @@
<span class="detail-meta-item"><span class="priority-dot priority-{{ item.priority }}"></span> P{{ item.priority }}</span> <span class="detail-meta-item"><span class="priority-dot priority-{{ item.priority }}"></span> P{{ item.priority }}</span>
{% if item.target_date %}<span class="detail-meta-item">Target: {{ item.target_date }}</span>{% endif %} {% if item.target_date %}<span class="detail-meta-item">Target: {{ item.target_date }}</span>{% endif %}
</div> </div>
<div class="mt-2" style="max-width: 300px;"> <a href="/tasks/?project_id={{ item.id }}" class="stat-card-link" style="max-width: 300px; display: block; margin-top: 8px;">
<div class="progress-bar"><div class="progress-fill" style="width: {{ progress }}%"></div></div> <div class="progress-bar"><div class="progress-fill" style="width: {{ progress }}%"></div></div>
<div class="progress-text">{{ done_count }}/{{ task_count }} tasks complete ({{ progress }}%)</div> <div class="progress-text">{{ done_count }}/{{ task_count }} tasks complete ({{ progress }}%)</div>
</div> </a>
</div> </div>
{% if item.description %} {% if item.description %}
@@ -34,8 +34,14 @@
<!-- Tabs --> <!-- Tabs -->
<div class="tab-strip"> <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=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=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 ({{ links|length }})</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=meetings" class="tab-item {{ 'active' if tab == 'meetings' }}">Meetings{% if counts.meetings %} ({{ counts.meetings }}){% 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> </div>
{% if tab == 'tasks' %} {% if tab == 'tasks' %}
@@ -91,5 +97,106 @@
{% else %} {% else %}
<div class="empty-state"><div class="empty-state-text">No links yet</div></div> <div class="empty-state"><div class="empty-state-text">No links yet</div></div>
{% endfor %} {% 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 == 'meetings' %}
<a href="/meetings/create" class="btn btn-ghost btn-sm mb-3">+ New Meeting</a>
<div class="card mb-4">
<form action="/projects/{{ item.id }}/meetings/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">Meeting</label>
<select name="meeting_id" class="form-select" required>
<option value="">Select meeting...</option>
{% for m in all_meetings %}
<option value="{{ m.id }}">{{ m.title }} ({{ m.meeting_date }})</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-primary btn-sm">Add</button>
</form>
</div>
{% for m in tab_data %}
<div class="list-row">
<span class="row-title"><a href="/meetings/{{ m.id }}">{{ m.title }}</a></span>
<span class="row-meta">{{ m.meeting_date }}</span>
<span class="status-badge status-{{ m.status }}">{{ m.status }}</span>
<div class="row-actions">
<form action="/projects/{{ item.id }}/meetings/{{ m.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 meetings 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 %} {% endif %}
{% endblock %} {% endblock %}

View File

@@ -54,6 +54,19 @@
</div> </div>
{% endif %} {% 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=links" class="tab-item {{ 'active' if tab == 'links' }}">Links{% if counts.links %} ({{ counts.links }}){% 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 %} {% if parent %}
<div class="card mb-4"> <div class="card mb-4">
<div class="card-title text-sm">Parent Task</div> <div class="card-title text-sm">Parent Task</div>
@@ -61,7 +74,6 @@
</div> </div>
{% endif %} {% endif %}
<!-- Subtasks -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h2 class="card-title">Subtasks<span class="page-count">{{ subtasks|length }}</span></h2> <h2 class="card-title">Subtasks<span class="page-count">{{ subtasks|length }}</span></h2>
@@ -84,6 +96,99 @@
{% endfor %} {% endfor %}
</div> </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 == 'links' %}
<a href="/links/create?task_id={{ item.id }}" class="btn btn-ghost btn-sm mb-3">+ New Link</a>
{% for w in tab_data %}
<div class="list-row">
<span class="row-title"><a href="{{ w.url }}" target="_blank">{{ w.label }}</a></span>
<span class="row-meta">{{ w.url[:50] }}{% if w.url|length > 50 %}...{% endif %}</span>
</div>
{% else %}
<div class="empty-state"><div class="empty-state-text">No links linked to this 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"> <div class="text-xs text-muted mt-4">
Created {{ item.created_at.strftime('%Y-%m-%d %H:%M') if item.created_at else '' }} 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 %} {% if item.completed_at %} | Completed {{ item.completed_at.strftime('%Y-%m-%d %H:%M') }}{% endif %}

View File

@@ -0,0 +1,76 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<div>
<h1 class="page-title">Time Budgets <span class="page-count">({{ count }})</span></h1>
</div>
<a href="/time-budgets/create" class="btn btn-primary">+ New Budget</a>
</div>
{% if overcommitted %}
<div class="alert alert-warning mb-3">
<strong>Overcommitted!</strong> Your budgets total {{ "%.1f"|format(total_budgeted) }} hours/week, which exceeds the 168 hours available.
</div>
{% endif %}
{% if current_budgets %}
<div class="card mb-4">
<div class="card-header">
<span class="card-title">This Week's Budget vs Actual</span>
<span class="text-muted text-sm">{{ "%.1f"|format(total_budgeted) }}h budgeted total</span>
</div>
{% for b in current_budgets %}
<div class="list-row" style="flex-wrap: wrap; gap: 8px;">
<span class="domain-dot" style="background: {{ b.domain_color or '#4F6EF7' }}; flex-shrink: 0;"></span>
<div class="row-title" style="min-width: 120px;">
{{ b.domain_name }}
</div>
<div style="flex: 2; min-width: 200px; display: flex; align-items: center; gap: 8px;">
<div class="progress-bar" style="flex: 1; height: 8px;">
<div class="progress-fill" style="width: {{ [b.pct, 100] | min }}%; {{ 'background: var(--red);' if b.pct > 100 }}"></div>
</div>
</div>
<span class="row-meta" style="min-width: 100px; text-align: right;">
<strong>{{ b.actual_hours }}h</strong> / {{ b.weekly_hours_float }}h
</span>
<span class="row-meta" style="min-width: 40px; text-align: right; {{ 'color: var(--red); font-weight: 600;' if b.pct > 100 else ('color: var(--green);' if b.pct >= 80 else '') }}">
{{ b.pct }}%
</span>
</div>
{% endfor %}
</div>
{% endif %}
{% if all_budgets %}
<div class="card">
<div class="card-header">
<span class="card-title">All Budgets</span>
</div>
{% for b in all_budgets %}
<div class="list-row">
<span class="domain-dot" style="background: {{ b.domain_color or '#4F6EF7' }}; flex-shrink: 0;"></span>
<div class="row-title">
{{ b.domain_name }}
</div>
<span class="row-meta">{{ b.weekly_hours }}h / week</span>
<span class="row-meta">from {{ b.effective_from.strftime('%b %-d, %Y') if b.effective_from else '—' }}</span>
<div class="row-actions">
<a href="/time-budgets/{{ b.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
<form method="POST" action="/time-budgets/{{ b.id }}/delete" data-confirm="Delete this budget?">
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red);">Delete</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-state-icon">&#9201;</div>
<div class="empty-state-text">No time budgets defined</div>
<a href="/time-budgets/create" class="btn btn-primary">Create a Budget</a>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<div>
<h1 class="page-title">{{ 'Edit Time Budget' if budget else 'New Time Budget' }}</h1>
</div>
</div>
<div class="card" style="max-width: 600px;">
<form method="POST" action="{{ '/time-budgets/' ~ budget.id ~ '/edit' if budget else '/time-budgets/create' }}">
<div class="form-grid" style="grid-template-columns: 1fr;">
<div class="form-group">
<label class="form-label">Domain *</label>
<select name="domain_id" class="form-select" required>
<option value="">Select domain...</option>
{% for d in domains %}
<option value="{{ d.id }}" {{ 'selected' if budget and budget.domain_id|string == d.id|string }}>
{{ d.name }}
</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label class="form-label">Weekly Hours *</label>
<input type="number" name="weekly_hours" class="form-input"
value="{{ budget.weekly_hours if budget else '' }}"
min="0" max="168" step="0.5" required
placeholder="e.g. 10">
</div>
<div class="form-group">
<label class="form-label">Effective From *</label>
<input type="date" name="effective_from" class="form-input"
value="{{ budget.effective_from.strftime('%Y-%m-%d') if budget and budget.effective_from else '' }}"
required>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'Save Changes' if budget else 'Create Budget' }}</button>
<a href="/time-budgets" class="btn btn-secondary">Cancel</a>
</div>
</div>
</form>
</div>
{% endblock %}

View File

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

View File

@@ -1,10 +1,10 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<div class="page-header"> <div class="page-header">
<h1 class="page-title">Weblinks<span class="page-count">{{ items|length }}</span></h1> <h1 class="page-title">Bookmarks<span class="page-count">{{ items|length }}</span></h1>
<div class="flex gap-2"> <div class="flex gap-2">
<a href="/weblinks/folders/create" class="btn btn-secondary">+ New Folder</a> <a href="/weblinks/folders/create" class="btn btn-secondary">+ New Folder</a>
<a href="/weblinks/create{{ '?folder_id=' ~ current_folder_id if current_folder_id }}" class="btn btn-primary">+ New Weblink</a> <a href="/weblinks/create{{ '?folder_id=' ~ current_folder_id if current_folder_id }}" class="btn btn-primary">+ New Link</a>
</div> </div>
</div> </div>
@@ -12,7 +12,7 @@
<!-- Folder sidebar --> <!-- Folder sidebar -->
<div class="weblinks-folders"> <div class="weblinks-folders">
<a href="/weblinks" class="weblink-folder-item {{ 'active' if not current_folder_id }}"> <a href="/weblinks" class="weblink-folder-item {{ 'active' if not current_folder_id }}">
All Weblinks All Links
</a> </a>
{% for folder in top_folders %} {% for folder in top_folders %}
<a href="/weblinks?folder_id={{ folder.id }}" class="weblink-folder-item {{ 'active' if current_folder_id == folder.id|string }}"> <a href="/weblinks?folder_id={{ folder.id }}" class="weblink-folder-item {{ 'active' if current_folder_id == folder.id|string }}">
@@ -28,7 +28,7 @@
{% endfor %} {% endfor %}
</div> </div>
<!-- Weblinks list --> <!-- Links list -->
<div class="weblinks-content"> <div class="weblinks-content">
{% if current_folder %} {% if current_folder %}
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
@@ -38,6 +38,23 @@
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Delete Folder</button> <button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Delete Folder</button>
</form> </form>
</div> </div>
{% if available_links %}
<div class="card" style="margin-bottom: 12px;">
<form action="/weblinks/folders/{{ current_folder.id }}/add-link" method="post"
style="display: flex; gap: 8px; align-items: end; padding: 12px;">
<div class="form-group" style="flex: 1; margin: 0;">
<label class="form-label">Add existing link</label>
<select name="link_id" class="form-select" required>
<option value="">Select link...</option>
{% for l in available_links %}
<option value="{{ l.id }}">{{ l.label }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-primary btn-sm">Add</button>
</form>
</div>
{% endif %}
{% endif %} {% endif %}
{% if items %} {% if items %}
@@ -56,8 +73,20 @@
{% endfor %} {% endfor %}
{% endif %} {% endif %}
<div class="row-actions"> <div class="row-actions">
{% if current_folder_id %}
<form action="/weblinks/folders/{{ current_folder_id }}/reorder" method="post" style="display:inline">
<input type="hidden" name="link_id" value="{{ item.id }}">
<input type="hidden" name="direction" value="up">
<button type="submit" class="btn btn-ghost btn-xs" title="Move up">&#9650;</button>
</form>
<form action="/weblinks/folders/{{ current_folder_id }}/reorder" method="post" style="display:inline">
<input type="hidden" name="link_id" value="{{ item.id }}">
<input type="hidden" name="direction" value="down">
<button type="submit" class="btn btn-ghost btn-xs" title="Move down">&#9660;</button>
</form>
{% endif %}
<a href="/weblinks/{{ item.id }}/edit" class="btn btn-ghost btn-xs">Edit</a> <a href="/weblinks/{{ item.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
<form action="/weblinks/{{ item.id }}/delete" method="post" data-confirm="Delete this weblink?" style="display:inline"> <form action="/weblinks/{{ item.id }}/delete" method="post" data-confirm="Delete this link?" style="display:inline">
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Del</button> <button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Del</button>
</form> </form>
</div> </div>
@@ -67,8 +96,8 @@
{% else %} {% else %}
<div class="empty-state"> <div class="empty-state">
<div class="empty-state-icon">&#128279;</div> <div class="empty-state-icon">&#128279;</div>
<div class="empty-state-text">No weblinks{{ ' in this folder' if current_folder }} yet</div> <div class="empty-state-text">No links{{ ' in this folder' if current_folder }} yet</div>
<a href="/weblinks/create{{ '?folder_id=' ~ current_folder_id if current_folder_id }}" class="btn btn-primary">Add Weblink</a> <a href="/weblinks/create{{ '?folder_id=' ~ current_folder_id if current_folder_id }}" class="btn btn-primary">Add Link</a>
</div> </div>
{% endif %} {% endif %}
</div> </div>

View File

@@ -23,12 +23,16 @@ SEED_IDS = {
"meeting": "a0000000-0000-0000-0000-000000000007", "meeting": "a0000000-0000-0000-0000-000000000007",
"decision": "a0000000-0000-0000-0000-000000000008", "decision": "a0000000-0000-0000-0000-000000000008",
"appointment": "a0000000-0000-0000-0000-000000000009", "appointment": "a0000000-0000-0000-0000-000000000009",
"weblink_folder": "a0000000-0000-0000-0000-00000000000a", "link_folder": "a0000000-0000-0000-0000-00000000000a",
"list": "a0000000-0000-0000-0000-00000000000b", "list": "a0000000-0000-0000-0000-00000000000b",
"link": "a0000000-0000-0000-0000-00000000000c", "link": "a0000000-0000-0000-0000-00000000000c",
"weblink": "a0000000-0000-0000-0000-00000000000d",
"capture": "a0000000-0000-0000-0000-00000000000e", "capture": "a0000000-0000-0000-0000-00000000000e",
"focus": "a0000000-0000-0000-0000-00000000000f", "focus": "a0000000-0000-0000-0000-00000000000f",
"process": "a0000000-0000-0000-0000-000000000010",
"process_step": "a0000000-0000-0000-0000-000000000011",
"process_run": "a0000000-0000-0000-0000-000000000012",
"time_budget": "a0000000-0000-0000-0000-000000000013",
"file": "a0000000-0000-0000-0000-000000000014",
} }
@@ -148,12 +152,12 @@ def all_seeds(sync_conn):
ON CONFLICT (id) DO NOTHING ON CONFLICT (id) DO NOTHING
""", (d["appointment"],)) """, (d["appointment"],))
# Weblink folder # Link folder
cur.execute(""" cur.execute("""
INSERT INTO weblink_folders (id, name, is_deleted, created_at, updated_at) INSERT INTO link_folders (id, name, is_deleted, created_at, updated_at)
VALUES (%s, 'Test Folder', false, now(), now()) VALUES (%s, 'Test Folder', false, now(), now())
ON CONFLICT (id) DO NOTHING ON CONFLICT (id) DO NOTHING
""", (d["weblink_folder"],)) """, (d["link_folder"],))
# List # List
cur.execute(""" cur.execute("""
@@ -169,18 +173,11 @@ def all_seeds(sync_conn):
ON CONFLICT (id) DO NOTHING ON CONFLICT (id) DO NOTHING
""", (d["link"], d["domain"])) """, (d["link"], d["domain"]))
# Weblink # Link folder junction
cur.execute(""" cur.execute("""
INSERT INTO weblinks (id, label, url, is_deleted, created_at, updated_at) INSERT INTO folder_links (folder_id, link_id)
VALUES (%s, 'Test Weblink', 'https://example.com/wl', false, now(), now())
ON CONFLICT (id) DO NOTHING
""", (d["weblink"],))
# Link weblink to folder via junction table
cur.execute("""
INSERT INTO folder_weblinks (folder_id, weblink_id)
VALUES (%s, %s) ON CONFLICT DO NOTHING VALUES (%s, %s) ON CONFLICT DO NOTHING
""", (d["weblink_folder"], d["weblink"])) """, (d["link_folder"], d["link"]))
# Capture # Capture
cur.execute(""" cur.execute("""
@@ -196,6 +193,67 @@ def all_seeds(sync_conn):
ON CONFLICT (id) DO NOTHING ON CONFLICT (id) DO NOTHING
""", (d["focus"], d["task"])) """, (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)
VALUES (%s, 'Test Process', 'checklist', 'active', 'Testing', false, now(), now())
ON CONFLICT (id) DO NOTHING
""", (d["process"],))
# Process step
cur.execute("""
INSERT INTO process_steps (id, process_id, title, instructions, sort_order, is_deleted, created_at, updated_at)
VALUES (%s, %s, 'Test Step', 'Do the thing', 0, false, now(), now())
ON CONFLICT (id) DO NOTHING
""", (d["process_step"], d["process"]))
# Process run
cur.execute("""
INSERT INTO process_runs (id, process_id, title, status, process_type, task_generation, is_deleted, created_at, updated_at)
VALUES (%s, %s, 'Test Run', 'not_started', 'checklist', 'all_at_once', false, now(), now())
ON CONFLICT (id) DO NOTHING
""", (d["process_run"], d["process"]))
# Time budget
cur.execute("""
INSERT INTO time_budgets (id, domain_id, weekly_hours, effective_from, is_deleted, created_at, updated_at)
VALUES (%s, %s, 10, CURRENT_DATE, false, now(), now())
ON CONFLICT (id) DO NOTHING
""", (d["time_budget"], d["domain"]))
# File (create a dummy file on disk for download/serve tests)
import os
from pathlib import Path
file_storage = os.getenv("FILE_STORAGE_PATH", "/opt/lifeos/files/dev")
Path(file_storage).mkdir(parents=True, exist_ok=True)
dummy_file_path = os.path.join(file_storage, "test_seed_file.txt")
with open(dummy_file_path, "w") as f:
f.write("test seed file content")
cur.execute("""
INSERT INTO files (id, filename, original_filename, storage_path, mime_type, size_bytes, is_deleted, created_at, updated_at)
VALUES (%s, 'test_seed_file.txt', 'test_seed_file.txt', %s, 'text/plain', 22, false, now(), now())
ON CONFLICT (id) DO NOTHING
""", (d["file"], dummy_file_path))
sync_conn.commit() sync_conn.commit()
except Exception as e: except Exception as e:
sync_conn.rollback() sync_conn.rollback()
@@ -205,13 +263,24 @@ def all_seeds(sync_conn):
# Cleanup: delete all seed data (reverse dependency order) # Cleanup: delete all seed data (reverse dependency order)
try: 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)
cur.execute("DELETE FROM time_budgets WHERE id = %s", (d["time_budget"],))
cur.execute("DELETE FROM process_runs WHERE id = %s", (d["process_run"],))
cur.execute("DELETE FROM process_steps WHERE id = %s", (d["process_step"],))
cur.execute("DELETE FROM processes WHERE id = %s", (d["process"],))
cur.execute("DELETE FROM daily_focus WHERE id = %s", (d["focus"],)) cur.execute("DELETE FROM daily_focus WHERE id = %s", (d["focus"],))
cur.execute("DELETE FROM capture WHERE id = %s", (d["capture"],)) cur.execute("DELETE FROM capture WHERE id = %s", (d["capture"],))
cur.execute("DELETE FROM folder_weblinks WHERE weblink_id = %s", (d["weblink"],)) cur.execute("DELETE FROM folder_links WHERE link_id = %s", (d["link"],))
cur.execute("DELETE FROM weblinks WHERE id = %s", (d["weblink"],))
cur.execute("DELETE FROM links WHERE id = %s", (d["link"],)) cur.execute("DELETE FROM links WHERE id = %s", (d["link"],))
cur.execute("DELETE FROM lists WHERE id = %s", (d["list"],)) cur.execute("DELETE FROM lists WHERE id = %s", (d["list"],))
cur.execute("DELETE FROM weblink_folders WHERE id = %s", (d["weblink_folder"],)) cur.execute("DELETE FROM link_folders WHERE id = %s", (d["link_folder"],))
cur.execute("DELETE FROM appointments WHERE id = %s", (d["appointment"],)) cur.execute("DELETE FROM appointments WHERE id = %s", (d["appointment"],))
cur.execute("DELETE FROM decisions WHERE id = %s", (d["decision"],)) cur.execute("DELETE FROM decisions WHERE id = %s", (d["decision"],))
cur.execute("DELETE FROM meetings WHERE id = %s", (d["meeting"],)) cur.execute("DELETE FROM meetings WHERE id = %s", (d["meeting"],))
@@ -273,3 +342,11 @@ def seed_note(all_seeds):
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def seed_meeting(all_seeds): def seed_meeting(all_seeds):
return {"id": all_seeds["meeting"], "title": "Test Meeting"} return {"id": all_seeds["meeting"], "title": "Test Meeting"}
@pytest.fixture(scope="session")
def seed_list(all_seeds):
return {"id": all_seeds["list"], "name": "Test List"}
@pytest.fixture(scope="session")
def seed_appointment(all_seeds):
return {"id": all_seeds["appointment"], "title": "Test Appointment"}

View File

@@ -34,6 +34,8 @@ FK_FIELD_MAP = {
"release_id": None, "release_id": None,
"note_id": "note", "note_id": "note",
"list_id": "list", "list_id": "list",
"process_id": "process",
"run_id": "process_run",
} }
# Field name pattern -> static test value # Field name pattern -> static test value
@@ -76,6 +78,8 @@ NAME_PATTERNS: list[tuple[str, Any]] = [
("location", "Test Location"), ("location", "Test Location"),
("tags", ""), ("tags", ""),
("notes", "Test notes field"), ("notes", "Test notes field"),
("weekly_hours", "10"),
("effective_from", None), # Resolved dynamically as date
] ]

View File

@@ -35,14 +35,20 @@ PREFIX_TO_SEED = {
"/lists": "list", "/lists": "list",
"/meetings": "meeting", "/meetings": "meeting",
"/decisions": "decision", "/decisions": "decision",
"/weblinks": "weblink", "/weblinks": "link",
"/weblinks/folders": "weblink_folder", "/weblinks/folders": "link_folder",
"/appointments": "appointment", "/appointments": "appointment",
"/focus": "focus", "/focus": "focus",
"/capture": "capture", "/capture": "capture",
"/time": "task", "/time": "task",
"/files": None, "/processes": "process",
"/processes/runs": "process_run",
"/time-budgets": "time_budget",
"/files": "file",
"/admin/trash": None, "/admin/trash": None,
"/history": None,
"/eisenhower": None,
"/calendar": None,
} }
def resolve_path(path_template, seeds): def resolve_path(path_template, seeds):

File diff suppressed because it is too large Load Diff

View File

@@ -33,10 +33,13 @@ from tests.form_factory import build_form_data, build_edit_data
_CREATE_ROUTES = [r for r in ALL_ROUTES if r.kind == RouteKind.CREATE and not r.has_file_upload] _CREATE_ROUTES = [r for r in ALL_ROUTES if r.kind == RouteKind.CREATE and not r.has_file_upload]
_EDIT_ROUTES = [r for r in ALL_ROUTES if r.kind == RouteKind.EDIT and not r.has_file_upload] _EDIT_ROUTES = [r for r in ALL_ROUTES if r.kind == RouteKind.EDIT and not r.has_file_upload]
_DELETE_ROUTES = [r for r in ALL_ROUTES if r.kind == RouteKind.DELETE] _DELETE_ROUTES = [r for r in ALL_ROUTES if r.kind == RouteKind.DELETE]
_ACTION_ROUTES = [r for r in ALL_ROUTES if r.kind in (RouteKind.ACTION, RouteKind.TOGGLE)] # Admin trash actions are excluded here — covered by TestAdminTrashLifecycle in test_business_logic.py
_ADMIN_TRASH_PATHS = {
# Destructive actions that wipe data other tests depend on "/admin/trash/empty",
_DESTRUCTIVE_ACTIONS = {"/admin/trash/empty", "/admin/trash/{table}/{item_id}/permanent-delete"} "/admin/trash/{table}/{item_id}/permanent-delete",
"/admin/trash/{table}/{item_id}/restore",
}
_ACTION_ROUTES = [r for r in ALL_ROUTES if r.kind in (RouteKind.ACTION, RouteKind.TOGGLE) and r.path not in _ADMIN_TRASH_PATHS]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -97,10 +100,6 @@ async def test_edit_redirects(client: AsyncClient, all_seeds: dict, route):
) )
async def test_action_does_not_crash(client: AsyncClient, all_seeds: dict, route): async def test_action_does_not_crash(client: AsyncClient, all_seeds: dict, route):
"""POST action routes should not return 500.""" """POST action routes should not return 500."""
# Skip destructive actions that would wipe seed data
if route.path in _DESTRUCTIVE_ACTIONS:
pytest.skip(f"Skipping destructive action {route.path}")
resolved = resolve_path(route.path, all_seeds) resolved = resolve_path(route.path, all_seeds)
if "{" in resolved: if "{" in resolved:
pytest.skip(f"No seed data mapping for {route.path}") pytest.skip(f"No seed data mapping for {route.path}")

102
tests/test_mobile_nav.py Normal file
View File

@@ -0,0 +1,102 @@
"""Tests that the mobile navigation bar is correctly structured and positioned."""
import re
import pytest
from httpx import AsyncClient, ASGITransport
from tests.registry import app
@pytest.fixture
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
@pytest.mark.asyncio
async def test_mob_nav_exists(client):
"""mob-nav element exists in page output."""
resp = await client.get("/")
assert resp.status_code == 200
assert 'class="mob-nav"' in resp.text, "mob-nav not found in HTML"
@pytest.mark.asyncio
async def test_mob_nav_is_direct_body_child(client):
"""mob-nav must be a direct child of body, not nested in any container."""
resp = await client.get("/")
html = resp.text
mob_idx = html.find('id="mobNav"')
body_close = html.find('</body>')
assert mob_idx != -1, "mobNav not found"
assert body_close != -1, "</body> not found"
between = html[mob_idx:body_close]
assert between.count('</main>') == 0, "mob-nav appears to be inside <main>"
assert between.count('</div></div></div>') == 0, "mob-nav appears deeply nested"
@pytest.mark.asyncio
async def test_mob_nav_has_five_items(client):
"""Bottom bar must have exactly 5 navigation items (4 links + 1 button)."""
resp = await client.get("/")
html = resp.text
start = html.find('id="mobNav"')
assert start != -1
# Scope to just the mob-nav element (ends at first </div> after it)
end = html.find('</div>', start)
chunk = html[start:end]
links = len(re.findall(r'<a\b', chunk))
buttons = len(re.findall(r'<button\b', chunk))
assert links == 4, f"Expected 4 link items, found {links}"
assert buttons == 1, f"Expected 1 button item, found {buttons}"
@pytest.mark.asyncio
async def test_mob_nav_has_inline_fixed_position(client):
"""mob-nav must have position:fixed as an inline style for maximum reliability."""
resp = await client.get("/")
assert 'id="mobNav" style="position:fixed' in resp.text, \
"mob-nav missing inline position:fixed style"
@pytest.mark.asyncio
async def test_mob_nav_css_has_fixed_position(client):
"""CSS must include position:fixed for mob-nav."""
css_resp = await client.get("/static/style.css")
css = css_resp.text
assert "position: fixed" in css or "position:fixed" in css, \
"No position:fixed found in CSS"
@pytest.mark.asyncio
async def test_mob_nav_inline_style_in_head(client):
"""Critical mob-nav styles must be inlined in <head> as a fallback."""
resp = await client.get("/")
html = resp.text
head_end = html.find('</head>')
head = html[:head_end]
assert '.mob-nav' in head, "No inline mob-nav styles found in <head>"
assert 'position:fixed' in head, "No position:fixed in inline <head> styles"
@pytest.mark.asyncio
async def test_mob_nav_not_inside_transformed_parent(client):
"""No ancestor of mob-nav should have transform that breaks position:fixed."""
resp = await client.get("/")
html = resp.text
mob_idx = html.find('id="mobNav"')
body_start = html.find('<body')
prefix = html[body_start:mob_idx]
opens = len(re.findall(r'<div\b[^>]*>', prefix))
closes = prefix.count('</div>')
nesting = opens - closes
assert nesting <= 1, \
f"mob-nav is nested {nesting} divs deep - must be 0 or 1 (direct body child)"
@pytest.mark.asyncio
async def test_mob_nav_present_on_all_pages(client):
"""mob-nav should appear on every page, not just dashboard."""
for path in ["/", "/tasks/", "/focus/", "/capture/", "/contacts/"]:
resp = await client.get(path)
assert 'id="mobNav"' in resp.text, f"mob-nav missing on {path}"