feat: tasks app, security hardening, mobile fixes, iOS app shell

- Custom SQLite task manager replacing TickTick wrapper
- 73 tasks migrated from TickTick across 15 projects
- RRULE recurrence engine with lazy materialization
- Dashboard tasks widget (desktop sidebar + mobile card)
- Tasks page with project tabs, add/edit/complete/delete
- Security: locked ports to localhost, removed old containers
- Gitea Actions runner configured and all 3 CI jobs passing
- Fixed mobile overflow on dashboard cards
- iOS Capacitor app shell (Second Brain)
- Frontend/backend guide docs for adding new services
- TickTick Google Calendar sync re-authorized

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-03-30 15:35:57 -05:00
parent 877021ff20
commit 6023ebf9d0
49 changed files with 5207 additions and 23 deletions

View File

@@ -8,14 +8,26 @@ Runs on push/PR to `master`. Three jobs:
2. **secret-scanning** — checks for tracked .env/.db files and hardcoded secret patterns
3. **dockerfile-lint** — verifies all Dockerfiles have `USER` (non-root) and `HEALTHCHECK`
## Prerequisites
## Runner Setup
These workflows require a **Gitea Actions runner** to be configured.
Without a runner, the workflows are committed but will not execute.
The runner is configured in the Gitea docker-compose at `/media/yusiboyz/Media/Scripts/gitea/docker-compose.yml`.
To set up a runner:
1. Go to Gitea → Site Administration → Runners
2. Register a runner (Docker-based or shell-based)
3. The workflows will automatically execute on the next push
**What was done:**
1. Added `[actions] ENABLED = true` to Gitea's `app.ini`
2. Added `runner` service (gitea/act_runner) to Gitea's docker-compose
3. Generated runner token via `docker exec -u git gitea gitea actions generate-runner-token`
4. Token stored in `/media/yusiboyz/Media/Scripts/gitea/.env` as `RUNNER_TOKEN`
5. Runner registered as `platform-runner` with labels: ubuntu-latest, ubuntu-24.04, ubuntu-22.04
See: https://docs.gitea.com/usage/actions/overview
**To regenerate token (if needed):**
```bash
cd /media/yusiboyz/Media/Scripts/gitea
docker exec -u git gitea gitea actions generate-runner-token
# Update .env with new RUNNER_TOKEN value
docker compose up -d runner
```
**To check runner status:**
```bash
docker logs gitea-runner
```

View File

@@ -5,6 +5,7 @@ on:
branches: [master]
pull_request:
branches: [master]
workflow_dispatch:
jobs:
dependency-audit:

33
claude.txt Normal file
View File

@@ -0,0 +1,33 @@
Issue `#8` is the remaining CI/security automation task.
Current state:
- Repo-side workflow already exists at `.gitea/workflows/security.yml`
- Runner setup notes already exist at `.gitea/README.md`
- The missing piece is operational: a Gitea Actions runner is not configured, so the workflow does not execute
Your job:
1. Re-verify the current repo state before changing anything.
2. Review:
- `.gitea/workflows/security.yml`
- `.gitea/README.md`
3. Add the minimal files, scripts, or compose service needed to make Gitea runner setup easy for this environment.
4. Document exact setup steps for running a Gitea Actions runner against this Gitea instance.
5. If live access is available, verify the runner can register and that the workflow actually executes.
6. Do not mark issue `#8` complete unless workflow execution is confirmed. Otherwise keep it `Partial` or `Blocked`.
What `#8` means:
- Automatically run dependency audits
- Automatically scan for tracked secrets/runtime DB files
- Automatically check Dockerfiles for non-root `USER` and `HEALTHCHECK`
Important constraints:
- Do not overstate completion
- Separate repo-side completion from operational completion
- If a runner token or Gitea admin action is required, document that as a manual step
- Do not change admin credentials during this pass
Expected output:
- `Completed:`
- `Partial:`
- `Blocked:`
- `Manual ops actions:`

View File

@@ -0,0 +1,77 @@
Work in the `platform` repo and correct your previous remediation pass.
Your previous status report overstated completion. I do not want optimistic summaries or "close enough" fixes.
You must re-audit the current repo state first and compare every completion claim against the actual code.
Critical instruction:
- Do not mark an issue `Completed` unless the code path is actually fixed end-to-end.
- If any part of an issue is still present in code, the issue is not complete.
- Renaming, documenting, or partially reducing a problem does NOT count as fully fixed unless the underlying behavior is actually resolved.
- If something is only improved but not eliminated, keep it `Partial`.
I want correctness, not a clean-looking report.
Specific examples of what was overstated and must be handled properly:
1. Trips TLS verification
- Do not say Trips TLS is fixed unless all remaining `ssl.CERT_NONE` and `check_hostname = False` usage is removed or intentionally narrowed with a strong documented reason.
- Re-check every remaining occurrence in `services/trips/server.py`.
- Verify the real runtime call paths, not just one helper or one integration.
- If any unsafe TLS bypass remains in active code, the issue is still partial.
2. Inventory and Budget CORS
- Do not say CORS is removed unless `app.use(cors())` is actually gone or replaced with a constrained, justified configuration.
- Re-check:
- `services/inventory/server.js`
- `services/budget/server.js`
- If permissive CORS still exists in either service, do not call that fixed.
3. Settings disconnect safety
- Do not say disconnect safety is fixed unless the Settings page actually has a confirmation or another real guardrail before disconnecting a service.
- Re-check:
- `frontend-v2/src/routes/(app)/settings/+page.svelte`
- If clicking Disconnect still immediately disconnects, this is not fixed.
4. Stale test/debug cleanup
- Do not say `/test` cleanup is complete while stale references remain in comments, logs, or startup output.
- Re-check:
- `services/inventory/server.js`
- If comments or logs still refer to `/test`, cleanup is incomplete.
5. Cosmetic vs real authz
- Do not treat navbar hiding as authorization.
- If app visibility is only cosmetic and direct URLs still work, say that explicitly.
- Do not write language implying hidden apps are access-controlled unless route-level enforcement exists.
Required workflow:
1. Re-audit the current repo state first.
2. List every claim from the previous "final status" that is not actually true in code.
3. Fix the remaining code completely where feasible.
4. Re-verify after each fix.
5. Only then update issue comments and status.
When deciding whether an issue is complete:
- `Completed` means the underlying behavior is actually resolved in code and verified.
- `Partial` means some meaningful work is done, but any real part of the problem still exists.
- `Blocked` means you cannot finish because of an external dependency or operational prerequisite.
Do not use these as excuses:
- "It is harmless"
- "It is dead code"
- "It is okay because internal"
- "The naming is clearer now"
- "The doc explains it"
Those may be useful notes, but they do not make an issue complete by themselves.
Expected output:
- Start with a short audit of inaccurate prior claims.
- Then fix the code.
- Then provide:
- `Completed:`
- `Partial:`
- `Blocked:`
- `Manual ops actions:`
Do not rotate or change admin credentials during this pass.

229
codex.txt Normal file
View File

@@ -0,0 +1,229 @@
Platform Codex — Full Build & Remediation Log
================================================
Date: 2026-03-29
PLATFORM OVERVIEW
=================
A premium SaaS-style personal dashboard integrating self-hosted services:
- Budget (Actual Budget)
- Inventory (NocoDB)
- Fitness (SparkyFitness)
- Trips
- Reader (Miniflux)
- Media (Shelfmark/Spotizerr/Booklore)
Stack: SvelteKit (Svelte 5 runes) + Python gateway + Express.js services
Orchestration: Docker Compose, 6 containers
Reverse proxy: Pangolin at dash.quadjourney.com
Gitea repo: gate.quadjourney.com/yusiboyz/platform
WHAT WAS BUILT
==============
1. Design System
- Unified CSS token system: spacing (--sp-*), radius (--radius-*),
shadows (--shadow-*), colors, typography (--text-*)
- Migrated 235 raw values across 31 files
- Documented in frontend-v2/DESIGN_SYSTEM.md
2. Gateway Modular Refactor
- Split 1878-line server.py into 15 modular files:
server.py (thin router), config.py, database.py, auth.py, proxy.py,
dashboard.py, plus integrations/ directory (booklore, kindle, image_proxy,
qbittorrent, etc.)
- ThreadingHTTPServer for concurrent requests
- 30-second per-user dashboard cache
3. Fitness API Integration
- Replaced all mock data with real SparkyFitness backend API calls
- AI food input with multi-item splitting (/api/fitness/foods/split)
- Canonical food storage with dedup (naive singularize + pre-creation check)
- Entry editing (quantity) and deletion
- Confirmation modal for resolved items with quantity +/-
- Local timezone date fix (was showing UTC = next day after 7pm CDT)
- Per-user goals editing in Settings
- Shared food database across users, independent fitness goals
4. Media App (Built from Scratch)
- Books tab: search via Shelfmark/Anna's Archive, download + auto-import
to Booklore, per-book library dropdown
- Music tab: Spotify search via Spotizerr, track/album/artist/playlist
search types, Spotify embed player, download progress polling
- Library tab: Booklore library browser (237 books), cover resolution via
ISBN -> Open Library with localStorage cache, book detail modal,
format badges (EPUB/PDF), library filter pills
- Send to Kindle: SMTP2GO API integration, per-book Kindle label selector
("Madiha"/"Hafsa"), sends from bookdrop or booklore-books volumes
- "After download, also send to" Kindle option on book search
5. Multi-User Support
- Created Madiha's account
- Per-user nav visibility (cosmetic only, documented as such)
- hiddenByUser map in +layout.server.ts
- Shared food database, independent fitness goals
6. Frontend Architecture
- SvelteKit with Svelte 5 runes ($state, $props, $derived, $effect, $bindable)
- SvelteKit hooks.server.ts for auth on Immich/Karakeep proxies
- Settings page: real API data, fitness goals editing, disconnect confirmation
dialog, theme toggle, sign out
SECURITY REMEDIATION (Gitea Issues #1-#10)
==========================================
All 10 issues completed. Re-audited and verified in code on 2026-03-29.
#2 Auth Boundary
- /api/auth/register disabled (403)
- Gateway admin seeded from ADMIN_USERNAME/ADMIN_PASSWORD env vars only
- Trips USERNAME/PASSWORD have no default fallback
- Fitness user seed requires env vars (no "changeme" default)
- All passwords use bcrypt
#3 Trips Sharing Security
- handle_share_api enforces password via X-Share-Password header + bcrypt
- share_password stored as bcrypt hash
- All plaintext password logging removed
- Existing plaintext passwords invalidated by migration
- Dead hash_password function removed
#4 Fitness Authorization
- All user_id query params enforced to authenticated user's own ID
- /api/users returns only current user
- Wildcard CORS removed
#5 Gateway Trust Model
- Inventory and budget require API keys (X-API-Key middleware)
- Token validation uses protected endpoints per service type
- /debug-nocodb removed from inventory
- /test removed from inventory
- NocoDB search filter sanitized (strips operator injection chars)
- SERVICE_LEVEL_AUTH renamed to GATEWAY_KEY_SERVICES
- Trust model documented in docs/trust-model.md
- Per-user vs gateway-key services clearly distinguished
- Known limitations documented (no per-user isolation on shared services)
#6 Repository Hygiene
- No .env or .db files tracked in git
- .gitignore covers: .env*, *.db*, services/**/.env, data/, test-results/
- .env.example updated with all current env vars (no secrets)
#7 Transport Security
- Gateway: _internal_ssl_ctx removed entirely (internal services use plain HTTP)
- Gateway: ssl import removed from config.py
- Gateway: proxy.py uses urlopen() without context parameter
- Gateway: logout cookie includes HttpOnly, Secure, SameSite=Lax
- Gateway: image proxy uses default TLS + domain allowlist + content-type validation
- Trips: all 5 CERT_NONE sites removed (OpenAI, Gemini, Google Places, Geocode)
- Inventory: permissive cors() removed AND dead cors import removed
- Budget: permissive cors() removed AND dead cors import removed
#8 Dependency Security
- Budget path-to-regexp vulnerability fixed
- .gitea/workflows/security.yml committed with 3 jobs + workflow_dispatch trigger
- Gitea Actions enabled ([actions] ENABLED = true in app.ini)
- Runner (gitea/act_runner) added to Gitea docker-compose
- Runner registered as platform-runner on gitea_gitea network
- Config sets container.network = gitea_gitea so job containers can git clone
- Runner token stored in /media/yusiboyz/Media/Scripts/gitea/.env
- All 3 jobs verified passing:
- dependency-audit: SUCCESS (npm audit on budget + frontend)
- secret-scanning: SUCCESS (no tracked .env/.db, no hardcoded secrets)
- dockerfile-lint: SUCCESS (all Dockerfiles have USER + HEALTHCHECK)
- Runner setup documented in .gitea/README.md
#9 Performance Hardening
- Inventory /issues: server-side NocoDB WHERE filter (no full scan)
- Inventory /needs-review-count: server-side filter + pageInfo.totalRows
- Budget /summary: 1-minute cache
- Budget /transactions/recent: 30-second cache
- Budget /uncategorized-count: 2-minute cache
- Budget buildLookups: 2-minute cache
- Gateway /api/dashboard: 30-second per-user cache
- Actual Budget per-account API constraint documented
#10 Deployment Hardening
- All 6 containers run as non-root (appuser/node)
- Health checks on gateway, trips, fitness, inventory, budget, frontend
- PYTHONUNBUFFERED=1 on all Python services
- Trips Dockerfile only copies server.py (not whole context)
- Frontend uses multi-stage build
RE-AUDIT FINDINGS (2026-03-29)
==============================
1 inaccuracy found in prior report: CORS dead imports (const cors = require('cors'))
remained in inventory/server.js and budget/server.js after app.use(cors()) was removed.
Fixed by removing the dead imports.
All other claims verified accurate in code:
- Trips TLS: zero CERT_NONE or check_hostname = False
- Settings disconnect: confirm() dialog present
- /test cleanup: no references remain
- Cosmetic nav: documented as cosmetic-only, no false authz claims
GITEA ACTIONS RUNNER SETUP (2026-03-29)
=======================================
Problem: Workflow existed in repo but no runner was configured to execute it.
What was done:
1. Added [actions] ENABLED = true to Gitea app.ini
File: /media/yusiboyz/Media/Scripts/gitea/gitea/gitea/conf/app.ini
2. Restarted Gitea to pick up config change
3. Generated runner token: docker exec -u git gitea gitea actions generate-runner-token
4. Added runner service to Gitea docker-compose:
File: /media/yusiboyz/Media/Scripts/gitea/docker-compose.yml
Image: gitea/act_runner:latest
Container: gitea-runner
5. Saved token in /media/yusiboyz/Media/Scripts/gitea/.env as RUNNER_TOKEN
6. First attempt: job containers created on auto-generated network, could not
reach server:3000 for git clone (hung on git fetch)
7. Fix: created /data/config.yaml inside runner with container.network = gitea_gitea
and set CONFIG_FILE=/data/config.yaml env var
8. Recreated runner container (docker compose up -d runner) to pick up env change
9. Triggered workflow via API: POST /api/v1/repos/yusiboyz/platform/actions/workflows/security.yml/dispatches
10. All 3 jobs ran to completion: dependency-audit, secret-scanning, dockerfile-lint = SUCCESS
11. Added workflow_dispatch trigger to security.yml for manual runs
12. Updated .gitea/README.md with setup documentation
Key detail: job containers must be on gitea_gitea network to resolve "server:3000"
for git operations. Without this, git fetch hangs indefinitely.
BUGS FIXED DURING BUILD
========================
- SERVICE_MAP import bug: captured empty dict at import time, fixed with module reference
- Gateway Dockerfile missing modules: only copied server.py, fixed to copy all .py + integrations/
- Non-root container permission denied: fixed with COPY --chown=appuser
- Fitness date timezone: toISOString() returns UTC, fixed with local date construction
- Dashboard fitness widget not updating: plain let vs $state() in Svelte 5
- Food library empty: /api/fitness/foods/recent returns entry-shaped data, fixed mapFood
- Book covers from search: double-wrapped image proxy URLs, fixed to proxy directly
- Booklore cover API returns HTML: switched to Open Library + Google Books fallback
- Booklore books API too slow (14s): moved to lazy client-side cover resolution
- Fitness entries orphaned after DB reset: reassigned to new user IDs
- Madiha accidentally disconnected fitness: added confirm() dialog
- Double Kindle sends: actually processed + delivered SMTP2GO events, added debounce
- Kindle email typo: lowercase L vs uppercase I in address
MANUAL OPS ACTIONS
==================
1. Store admin password securely (set via ADMIN_PASSWORD env var)
2. Clean up local untracked .env files with real credentials if needed
3. Monitor @sveltejs/kit for a non-breaking cookie fix in future releases
ARCHITECTURE REFERENCE
======================
- Trust model: docs/trust-model.md
- CI workflows: .gitea/workflows/security.yml
- Runner setup: .gitea/README.md
- Design system: frontend-v2/DESIGN_SYSTEM.md
- Env var reference: .env.example
- Gitea instance: localhost:3300 (gate.quadjourney.com)
- Gitea compose: /media/yusiboyz/Media/Scripts/gitea/docker-compose.yml
- Platform compose: /media/yusiboyz/Media/Scripts/platform/docker-compose.yml

View File

@@ -5,10 +5,8 @@ services:
dockerfile: Dockerfile
container_name: platform-frontend-v2
restart: unless-stopped
ports:
- "3211:3000"
environment:
- ORIGIN=${PLATFORM_V2_ORIGIN:-http://localhost:3211}
- ORIGIN=${PLATFORM_V2_ORIGIN:-https://dash.quadjourney.com}
- GATEWAY_URL=http://gateway:8100
- IMMICH_URL=${IMMICH_URL}
- IMMICH_API_KEY=${IMMICH_API_KEY}
@@ -16,6 +14,10 @@ services:
- KARAKEEP_API_KEY=${KARAKEEP_API_KEY}
- BODY_SIZE_LIMIT=52428800
- TZ=${TZ:-America/Chicago}
networks:
default:
pangolin:
ipv4_address: 172.16.1.50
depends_on:
- gateway
@@ -53,6 +55,8 @@ services:
- KARAKEEP_API_KEY=${KARAKEEP_API_KEY}
- SPOTIZERR_URL=${SPOTIZERR_URL:-http://spotizerr-app:7171}
- BUDGET_BACKEND_URL=http://budget-service:3001
- TASKS_BACKEND_URL=http://tasks-service:8098
- TASKS_SERVICE_API_KEY=${TASKS_SERVICE_API_KEY}
- QBITTORRENT_HOST=${QBITTORRENT_HOST:-192.168.1.42}
- QBITTORRENT_PORT=${QBITTORRENT_PORT:-8080}
- QBITTORRENT_USERNAME=${QBITTORRENT_USERNAME:-admin}
@@ -72,6 +76,19 @@ services:
- fitness-service
- inventory-service
- budget-service
- tasks-service
tasks-service:
build:
context: ./services/tasks
dockerfile: Dockerfile
container_name: platform-tasks-service
restart: unless-stopped
volumes:
- ./services/tasks/data:/app/data
environment:
- PORT=8098
- TZ=${TZ:-America/Chicago}
trips-service:
build:

281
docs/new-service-guide.md Normal file
View File

@@ -0,0 +1,281 @@
# Adding a New Backend Service
Step-by-step for adding a new backend service to the platform.
---
## 1. Create the service directory
```
services/yourservice/
server.py # or server.js
Dockerfile
.env # (optional, for local secrets)
data/ # (created at runtime, for SQLite services)
```
## 2. Python service template
```python
import json
import os
import sqlite3
from http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path
from threading import Lock
PORT = int(os.environ.get("PORT", 8099))
DATA_DIR = Path(os.environ.get("DATA_DIR", "/app/data"))
DB_PATH = DATA_DIR / "yourservice.db"
# --- Database ---
def get_db():
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("PRAGMA journal_mode = WAL")
return conn
def init_db():
DATA_DIR.mkdir(parents=True, exist_ok=True)
conn = get_db()
c = conn.cursor()
c.execute('''CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)''')
conn.commit()
conn.close()
# --- Migrations (add columns safely) ---
# try:
# c.execute("ALTER TABLE items ADD COLUMN description TEXT DEFAULT ''")
# except: pass
# --- Handler ---
class Handler(BaseHTTPRequestHandler):
def _read_body(self):
length = int(self.headers.get("Content-Length", 0))
return json.loads(self.rfile.read(length)) if length else {}
def _send_json(self, data, status=200):
body = json.dumps(data).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
path = self.path.split("?")[0]
# Health check (before auth, unauthenticated)
if path == "/api/health":
self._send_json({"status": "ok"})
return
# --- Your routes ---
if path == "/api/items":
conn = get_db()
rows = conn.execute("SELECT * FROM items ORDER BY created_at DESC").fetchall()
conn.close()
self._send_json({"items": [dict(r) for r in rows]})
return
self._send_json({"error": "Not found"}, 404)
def do_POST(self):
path = self.path.split("?")[0]
body = self._read_body()
if path == "/api/items":
conn = get_db()
c = conn.cursor()
c.execute("INSERT INTO items (name) VALUES (?)", (body.get("name", ""),))
conn.commit()
item_id = c.lastrowid
conn.close()
self._send_json({"id": item_id}, 201)
return
self._send_json({"error": "Not found"}, 404)
def log_message(self, format, *args):
pass # Suppress request logs (gateway logs instead)
# --- Start ---
if __name__ == "__main__":
init_db()
from http.server import ThreadingHTTPServer
server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
print(f"Service listening on port {PORT}")
server.serve_forever()
```
## 3. Node/Express service template
```javascript
const express = require('express');
const app = express();
const port = process.env.PORT || 3099;
app.use(express.json());
// Health (before auth middleware)
app.get('/health', (req, res) => res.json({ status: 'ok' }));
// API key auth middleware
const SERVICE_API_KEY = process.env.SERVICE_API_KEY || '';
if (SERVICE_API_KEY) {
app.use((req, res, next) => {
const key = req.headers['x-api-key'] || req.query.api_key;
if (key !== SERVICE_API_KEY) {
return res.status(401).json({ error: 'Unauthorized' });
}
next();
});
}
// Routes
app.get('/items', async (req, res) => {
// ...
});
app.listen(port, () => console.log(`Listening on ${port}`));
```
## 4. Dockerfile (Python)
```dockerfile
FROM python:3.12-slim
WORKDIR /app
# Install dependencies (add as needed)
RUN pip install --no-cache-dir bcrypt
# Non-root user
RUN adduser --disabled-password --no-create-home appuser
RUN mkdir -p /app/data && chown -R appuser /app/data
COPY --chown=appuser server.py .
EXPOSE 8099
ENV PYTHONUNBUFFERED=1
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD python3 -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8099/api/health', timeout=3)" || exit 1
USER appuser
CMD ["python3", "server.py"]
```
## 5. Dockerfile (Node)
```dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY server.js ./
EXPOSE 3099
ENV NODE_ENV=production
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -qO- http://127.0.0.1:3099/health || exit 1
USER node
CMD ["node", "server.js"]
```
## 6. Docker Compose entry
Add to `docker-compose.yml`:
```yaml
yourservice:
build:
context: ./services/yourservice
dockerfile: Dockerfile
container_name: platform-yourservice
restart: unless-stopped
volumes:
- ./services/yourservice/data:/app/data
environment:
- PORT=8099
- SERVICE_API_KEY=${YOURSERVICE_API_KEY} # for API-key auth
- TZ=${TZ:-America/Chicago}
```
Add to the `gateway` service:
```yaml
gateway:
environment:
- YOURSERVICE_BACKEND_URL=http://yourservice:8099
- YOURSERVICE_API_KEY=${YOURSERVICE_API_KEY}
depends_on:
- yourservice
```
## 7. Gateway integration
### a) Register the app in `gateway/database.py`
Add a migration block in `_seed_apps()`:
```python
try:
c.execute("""INSERT INTO apps (id, name, icon, route_prefix, proxy_target, sort_order, enabled, dashboard_widget)
VALUES ('yourservice', 'Your Service', 'icon-name', '/yourservice', ?, 8, 1, NULL)""",
(YOURSERVICE_BACKEND_URL,))
except: pass
```
### b) Add URL to `gateway/config.py`
```python
YOURSERVICE_BACKEND_URL = os.environ.get("YOURSERVICE_BACKEND_URL", "http://yourservice:8099")
```
### c) Add auth injection in `gateway/server.py`
In the `_proxy()` method, add to the credential injection block:
```python
elif service_id == "yourservice":
headers["X-API-Key"] = YOURSERVICE_API_KEY
```
### d) If Node/Express: skip `/api` prefix
In `gateway/proxy.py`, add to `NO_API_PREFIX_SERVICES`:
```python
NO_API_PREFIX_SERVICES = {"inventory", "budget", "yourservice"}
```
This makes `/api/yourservice/items` proxy to `http://yourservice:8099/items`
instead of `http://yourservice:8099/api/items`.
Python services typically expect the `/api` prefix, so don't add them here.
## 8. Checklist
- [ ] `services/yourservice/server.py` (or `.js`) with `/health` endpoint
- [ ] `services/yourservice/Dockerfile` — non-root user, healthcheck, minimal copy
- [ ] `docker-compose.yml` — service entry + gateway env vars + depends_on
- [ ] `gateway/config.py` — backend URL from env
- [ ] `gateway/database.py` — app registration in seed
- [ ] `gateway/server.py` — auth header injection in `_proxy()`
- [ ] `gateway/proxy.py` — add to `NO_API_PREFIX_SERVICES` if Node
- [ ] `frontend-v2/src/routes/(app)/yourservice/+page.svelte` — UI page
- [ ] Nav registration in `+layout.server.ts`, `Navbar.svelte`, `MobileTabBar.svelte`
- [ ] `.env` — add `YOURSERVICE_API_KEY` (or other secrets)
- [ ] `docker compose build yourservice gateway && docker compose up -d`

View File

@@ -322,3 +322,146 @@ Shadows that are close to tokens but intentionally differ in blur radius or opac
| `0 20px 60px rgba(0,0,0,0.15)` | `--shadow-xl` | Single layer |
| `0 20px 60px rgba(0,0,0,0.2)` | `--shadow-xl` | Single layer, higher opacity |
| `0 1px 3px rgba(0,0,0,0.15)` | `--shadow-sm` | Toggle thumb, much higher opacity |
---
## Adding a New App (Frontend Guide)
Step-by-step for adding a new app page to the platform.
### 1. Create the route
Create `src/routes/(app)/yourapp/+page.svelte`. Every app is a single self-contained file.
### 2. Page structure template
```svelte
<script lang="ts">
import { onMount } from 'svelte';
// -- Types --
interface Item {
id: string;
name: string;
}
// -- State (Svelte 5 runes) --
let loading = $state(true);
let items = $state<Item[]>([]);
let activeTab = $state<'all' | 'recent'>('all');
let searchQuery = $state('');
// -- Derived --
const filtered = $derived(
items.filter(i => i.name.toLowerCase().includes(searchQuery.toLowerCase()))
);
// -- API helper (scoped to this app's gateway prefix) --
async function api(path: string, opts: RequestInit = {}) {
const res = await fetch(`/api/yourapp${path}`, { credentials: 'include', ...opts });
if (!res.ok) throw new Error(`${res.status}`);
return res.json();
}
// -- Data mapper (normalize backend shape to UI interface) --
function mapItem(raw: any): Item {
return { id: raw.id || raw.Id, name: raw.name || raw.title || '' };
}
// -- Load --
async function loadItems() {
try {
const data = await api('/items');
items = (data.items || data).map(mapItem);
} catch (e) { console.error('Failed to load items', e); }
}
onMount(async () => {
await loadItems();
loading = false;
});
</script>
<div class="page">
<div class="page-header">
<h1 class="page-title">Your App</h1>
</div>
{#if loading}
<div class="module"><div class="skeleton" style="height: 200px"></div></div>
{:else}
<div class="tab-bar">
<button class="tab" class:active={activeTab === 'all'} onclick={() => activeTab = 'all'}>All</button>
<button class="tab" class:active={activeTab === 'recent'} onclick={() => activeTab = 'recent'}>Recent</button>
</div>
<div class="module">
<div class="module-header">
<span class="module-title">Items</span>
<button class="module-action" onclick={...}>View all &rarr;</button>
</div>
{#each filtered as item}
<div class="data-row">
<span style="color: var(--text-1)">{item.name}</span>
</div>
{:else}
<p style="color: var(--text-3); padding: var(--card-pad)">No items found</p>
{/each}
</div>
{/if}
</div>
```
### 3. Register in navigation
**`(app)/+layout.server.ts`** -- add ID to allApps:
```ts
const allApps = ['trips', 'fitness', 'inventory', 'budget', 'reader', 'media', 'yourapp'];
```
**`Navbar.svelte`** -- add link:
```svelte
{#if showApp('yourapp')}
<a href="/yourapp" class="navbar-link" class:active={isActive('/yourapp')}>Your App</a>
{/if}
```
**`MobileTabBar.svelte`** -- add to primary tabs or the "More" sheet.
### 4. Key conventions
| Rule | Detail |
|------|--------|
| All state uses `$state()` | Never plain `let` for reactive values |
| Computed values use `$derived()` | For filtered lists, counts, conditions |
| API calls go through `/api/yourapp/*` | Gateway proxies to backend |
| `credentials: 'include'` on every fetch | Cookie-based auth |
| Map raw API data through `mapX()` | Normalize backend shapes to clean interfaces |
| No shared stores or context | Each page is self-contained |
| No separate `+page.ts` load function | Data loads in `onMount` |
| Types defined inline in script | No shared type files |
### 5. UI component classes to use
| Class | When |
|-------|------|
| `.page` / `.page-header` / `.page-title` | Page-level layout |
| `.module` / `.module.primary` / `.module.flush` | Card containers |
| `.module-header` / `.module-title` / `.module-action` | Card headers |
| `.data-row` | List items with hover + zebra |
| `.badge` + `.error/.success/.warning/.accent/.muted` | Status indicators |
| `.tab-bar` + `.tab` | Pill-style tabs |
| `.btn-primary` / `.btn-secondary` / `.btn-icon` | Buttons |
| `.input` | Text inputs |
| `.skeleton` | Loading placeholders |
| `.section-label` | Uppercase group header |
### 6. Styling rules
- All colors from tokens: `var(--text-1)`, `var(--accent)`, `var(--card)`, etc.
- All spacing from tokens: `var(--sp-3)`, `var(--card-pad)`, `var(--row-gap)`, etc.
- All radii from tokens: `var(--radius)`, `var(--radius-md)`, etc.
- All shadows from tokens: `var(--shadow-md)`, `var(--shadow-lg)`, etc.
- All font sizes from tokens: `var(--text-sm)`, `var(--text-base)`, etc.
- Never use raw `#hex` colors, raw `px` spacing, or raw shadows unless listed in the Intentional Raw Values section above
- Dark mode is automatic via CSS custom properties — no manual `prefers-color-scheme` needed

View File

@@ -16,7 +16,7 @@ COPY --from=builder /app/node_modules ./node_modules
EXPOSE 3000
ENV NODE_ENV=production
HEALTHCHECK --interval=30s --timeout=5s --retries=3 CMD wget -qO- http://localhost:3000/ || exit 1
HEALTHCHECK --interval=30s --timeout=5s --retries=3 CMD wget -qO- http://127.0.0.1:3000/ || exit 1
USER node
CMD ["node", "build"]

View File

@@ -12,6 +12,8 @@
═══════════════════════════════════════════════ */
@layer base {
html, body { overflow-x: hidden; }
body { padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); }
:root {
/* ── Fonts ── */
--font: 'Inter', -apple-system, system-ui, sans-serif;
@@ -457,5 +459,6 @@
}
.page-greeting { font-size: var(--text-xl); }
.page { padding: var(--sp-5) 0 var(--sp-20); }
.app-surface { padding: 0 var(--sp-5); }
.app-surface { padding: 0 var(--sp-5); max-width: 100vw; box-sizing: border-box; }
.module, .module.primary, .module.flush { box-sizing: border-box; max-width: 100%; overflow: hidden; }
}

View File

@@ -0,0 +1,237 @@
<script lang="ts">
import { onMount } from 'svelte';
interface Task {
id: string;
title: string;
projectId: string;
_projectName: string;
_projectId: string;
dueDate?: string;
startDate?: string;
isAllDay?: boolean;
priority: number;
}
let loading = $state(true);
let todayTasks = $state<Task[]>([]);
let overdueTasks = $state<Task[]>([]);
let completing = $state<Set<string>>(new Set());
async function api(path: string, opts: RequestInit = {}) {
const res = await fetch(`/api/tasks${path}`, { credentials: 'include', ...opts });
if (!res.ok) throw new Error(`${res.status}`);
return res.json();
}
function formatTime(task: Task): string {
const d = task.startDate || task.dueDate;
if (!d || task.isAllDay) return '';
try {
const date = new Date(d);
const h = date.getHours();
const m = date.getMinutes();
if (h === 0 && m === 0) return '';
const ampm = h >= 12 ? 'PM' : 'AM';
const hour = h % 12 || 12;
return m > 0 ? `${hour}:${String(m).padStart(2, '0')} ${ampm}` : `${hour} ${ampm}`;
} catch { return ''; }
}
function formatOverdueDate(task: Task): string {
const d = task.dueDate || task.startDate;
if (!d) return '';
try {
const date = new Date(d);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const taskDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const diff = Math.round((taskDate.getTime() - today.getTime()) / 86400000);
if (diff === -1) return 'Yesterday';
if (diff < -1) return `${Math.abs(diff)}d ago`;
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
} catch { return ''; }
}
function sortByTime(tasks: Task[]): Task[] {
return [...tasks].sort((a, b) => {
const aDate = a.startDate || a.dueDate || '9999';
const bDate = b.startDate || b.dueDate || '9999';
return aDate.localeCompare(bDate);
});
}
async function completeTask(task: Task) {
completing = new Set([...completing, task.id]);
try {
await api(`/tasks/${task.id}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectId: task.projectId || task._projectId })
});
setTimeout(loadTasks, 300);
} catch {
completing = new Set([...completing].filter(id => id !== task.id));
}
}
async function loadTasks() {
try {
const data = await api('/today');
overdueTasks = sortByTime(data.overdue || []).slice(0, 5);
todayTasks = sortByTime(data.today || []).slice(0, 5);
loading = false;
} catch { loading = false; }
}
onMount(loadTasks);
</script>
<div class="module tasks-mobile-module">
<div class="module-header">
<div class="module-title">Tasks</div>
<a href="/tasks" class="module-action">View all &rarr;</a>
</div>
{#if loading}
<div class="skeleton" style="height: 80px"></div>
{:else if overdueTasks.length === 0 && todayTasks.length === 0}
<div class="tm-empty">All clear for today</div>
{:else}
{#if overdueTasks.length > 0}
<div class="tm-section overdue">Overdue · {overdueTasks.length}</div>
{#each overdueTasks as task (task.id)}
<div class="tm-row" class:completing={completing.has(task.id)}>
<button class="tm-check" onclick={() => completeTask(task)}>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<rect x="1.5" y="1.5" width="17" height="17" rx="4" stroke="var(--error)" stroke-width="2"/>
</svg>
</button>
<div class="tm-body">
<span class="tm-title">{task.title}</span>
<span class="tm-time">{formatOverdueDate(task)}{#if formatTime(task)} · {formatTime(task)}{/if}</span>
</div>
<span class="tm-project">{task._projectName}</span>
</div>
{/each}
{/if}
{#if todayTasks.length > 0}
<div class="tm-section">Today · {todayTasks.length}</div>
{#each todayTasks as task (task.id)}
<div class="tm-row" class:completing={completing.has(task.id)}>
<button class="tm-check" onclick={() => completeTask(task)}>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<rect x="1.5" y="1.5" width="17" height="17" rx="4" stroke="var(--border-strong)" stroke-width="2"/>
</svg>
</button>
<div class="tm-body">
<span class="tm-title">{task.title}</span>
{#if formatTime(task)}
<span class="tm-time">{formatTime(task)}</span>
{/if}
</div>
<span class="tm-project">{task._projectName}</span>
</div>
{/each}
{/if}
{/if}
</div>
<style>
.tasks-mobile-module {
display: none;
}
@media (max-width: 1100px) {
.tasks-mobile-module {
display: block;
margin-bottom: var(--module-gap);
}
}
.tm-section {
font-size: var(--text-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-3);
padding: var(--sp-3) 0 var(--sp-1);
}
.tm-section.overdue {
color: var(--error);
}
.tm-row {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-3) 0;
border-bottom: 1px solid var(--border);
transition: opacity 0.3s;
}
.tm-row:last-child {
border-bottom: none;
}
.tm-row.completing {
opacity: 0.2;
text-decoration: line-through;
}
.tm-check {
flex-shrink: 0;
padding: 0;
background: none;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.tm-check:hover svg rect {
stroke: var(--accent);
fill: var(--accent-dim);
}
.tm-body {
flex: 1;
min-width: 0;
}
.tm-title {
display: block;
font-size: var(--text-base);
font-weight: 500;
color: var(--text-1);
line-height: var(--leading-snug);
}
.tm-time {
display: block;
font-size: var(--text-sm);
color: var(--text-3);
margin-top: 2px;
}
.tm-project {
flex-shrink: 0;
font-size: var(--text-sm);
color: var(--text-3);
text-align: right;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tm-empty {
padding: var(--sp-6) 0;
text-align: center;
color: var(--text-3);
font-size: var(--text-sm);
}
</style>

View File

@@ -0,0 +1,390 @@
<script lang="ts">
import { onMount } from 'svelte';
interface Task {
id: string;
title: string;
projectId: string;
_projectName: string;
dueDate?: string;
startDate?: string;
isAllDay?: boolean;
priority: number;
status: number;
}
let loading = $state(true);
let error = $state(false);
let todayTasks = $state<Task[]>([]);
let overdueTasks = $state<Task[]>([]);
let showAdd = $state(false);
let newTaskTitle = $state('');
let saving = $state(false);
let projects = $state<{ id: string; name: string }[]>([]);
let selectedProject = $state('');
let completing = $state<Set<string>>(new Set());
async function api(path: string, opts: RequestInit = {}) {
const res = await fetch(`/api/tasks${path}`, { credentials: 'include', ...opts });
if (!res.ok) throw new Error(`${res.status}`);
return res.json();
}
function formatTime(task: Task): string {
const d = task.startDate || task.dueDate;
if (!d || task.isAllDay) return '';
try {
const date = new Date(d);
const h = date.getHours();
const m = date.getMinutes();
if (h === 0 && m === 0) return '';
const ampm = h >= 12 ? 'PM' : 'AM';
const hour = h % 12 || 12;
return m > 0 ? `${hour}:${String(m).padStart(2, '0')} ${ampm}` : `${hour} ${ampm}`;
} catch { return ''; }
}
function priorityClass(p: number): string {
if (p >= 5) return 'priority-high';
if (p >= 3) return 'priority-med';
return '';
}
async function loadTasks() {
try {
const data = await api('/today');
todayTasks = data.today || [];
overdueTasks = data.overdue || [];
loading = false;
} catch {
error = true;
loading = false;
}
}
async function loadProjects() {
try {
const data = await api('/projects');
projects = (data.projects || []).map((p: any) => ({ id: p.id, name: p.name }));
if (projects.length > 0 && !selectedProject) {
selectedProject = projects[0].id;
}
} catch { /* silent */ }
}
async function addTask() {
if (!newTaskTitle.trim() || !selectedProject) return;
saving = true;
try {
const now = new Date();
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}T00:00:00+0000`;
await api('/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: newTaskTitle.trim(),
projectId: selectedProject,
dueDate: todayStr,
isAllDay: true,
})
});
newTaskTitle = '';
showAdd = false;
await loadTasks();
} catch (e) { console.error('Failed to add task', e); }
saving = false;
}
async function completeTask(task: Task) {
completing = new Set([...completing, task.id]);
try {
await api(`/tasks/${task.id}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectId: task.projectId || task._projectId })
});
// Animate out then reload
setTimeout(() => loadTasks(), 300);
} catch (e) {
console.error('Failed to complete task', e);
completing = new Set([...completing].filter(id => id !== task.id));
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') addTask();
if (e.key === 'Escape') { showAdd = false; newTaskTitle = ''; }
}
onMount(() => {
loadTasks();
loadProjects();
});
</script>
<div class="tasks-panel">
<div class="tasks-header">
<span class="tasks-title">Tasks</span>
<button class="tasks-add-btn" onclick={() => { showAdd = !showAdd; if (!showAdd) newTaskTitle = ''; }}>
{showAdd ? '×' : '+'}
</button>
</div>
{#if showAdd}
<div class="tasks-add-form">
<input
class="input tasks-input"
placeholder="Add a task..."
bind:value={newTaskTitle}
onkeydown={handleKeydown}
disabled={saving}
/>
<select class="input tasks-select" bind:value={selectedProject}>
{#each projects as proj}
<option value={proj.id}>{proj.name}</option>
{/each}
</select>
<button class="btn-primary tasks-submit" onclick={addTask} disabled={saving || !newTaskTitle.trim()}>
{saving ? '...' : 'Add'}
</button>
</div>
{/if}
{#if loading}
<div class="tasks-loading">
<div class="skeleton" style="height: 32px; margin-bottom: 8px"></div>
<div class="skeleton" style="height: 32px; margin-bottom: 8px"></div>
<div class="skeleton" style="height: 32px"></div>
</div>
{:else if error}
<div class="tasks-empty">Could not load tasks</div>
{:else}
{#if overdueTasks.length > 0}
<div class="tasks-section-label overdue-label">Overdue · {overdueTasks.length}</div>
{#each overdueTasks as task (task.id)}
<div class="task-row" class:completing={completing.has(task.id)}>
<button class="task-check" onclick={() => completeTask(task)} title="Complete">
<span class="task-check-circle"></span>
</button>
<div class="task-content">
<span class="task-title {priorityClass(task.priority)}">{task.title}</span>
<span class="task-meta">{task._projectName}</span>
</div>
</div>
{/each}
{/if}
{#if todayTasks.length > 0}
<div class="tasks-section-label">Today · {todayTasks.length}</div>
{#each todayTasks as task (task.id)}
<div class="task-row" class:completing={completing.has(task.id)}>
<button class="task-check" onclick={() => completeTask(task)} title="Complete">
<span class="task-check-circle"></span>
</button>
<div class="task-content">
<span class="task-title {priorityClass(task.priority)}">{task.title}</span>
<span class="task-meta">
{#if formatTime(task)}{formatTime(task)} · {/if}{task._projectName}
</span>
</div>
</div>
{/each}
{/if}
{#if todayTasks.length === 0 && overdueTasks.length === 0}
<div class="tasks-empty">All clear for today</div>
{/if}
{/if}
<a href="/tasks" class="tasks-footer">View all tasks &rarr;</a>
</div>
<style>
.tasks-panel {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
padding: var(--card-pad);
width: 280px;
max-height: calc(100vh - 120px);
overflow-y: auto;
position: sticky;
top: 80px;
}
.tasks-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--sp-3);
}
.tasks-title {
font-size: var(--text-sm);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-3);
}
.tasks-add-btn {
width: 28px;
height: 28px;
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--card-secondary);
color: var(--text-2);
font-size: var(--text-lg);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
transition: background 0.15s, color 0.15s;
}
.tasks-add-btn:hover {
background: var(--accent-dim);
color: var(--accent);
}
.tasks-add-form {
display: flex;
flex-direction: column;
gap: var(--sp-2);
margin-bottom: var(--sp-3);
padding-bottom: var(--sp-3);
border-bottom: 1px solid var(--border);
}
.tasks-input {
font-size: var(--text-sm) !important;
padding: var(--sp-2) var(--sp-3) !important;
}
.tasks-select {
font-size: var(--text-sm) !important;
padding: var(--sp-1.5) var(--sp-3) !important;
background: var(--card-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
color: var(--text-2);
}
.tasks-submit {
font-size: var(--text-sm) !important;
padding: var(--sp-1.5) var(--sp-3) !important;
}
.tasks-section-label {
font-size: var(--text-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-3);
padding: var(--sp-2) 0 var(--sp-1);
}
.overdue-label {
color: var(--error);
}
.task-row {
display: flex;
align-items: flex-start;
gap: var(--sp-2);
padding: var(--sp-1.5) 0;
transition: opacity 0.3s;
}
.task-row.completing {
opacity: 0.3;
text-decoration: line-through;
}
.task-check {
flex-shrink: 0;
width: 20px;
height: 20px;
padding: 0;
margin-top: 1px;
background: none;
border: none;
cursor: pointer;
}
.task-check-circle {
display: block;
width: 18px;
height: 18px;
border-radius: var(--radius-full);
border: 2px solid var(--border-strong);
transition: background 0.15s, border-color 0.15s;
}
.task-check:hover .task-check-circle {
border-color: var(--accent);
background: var(--accent-dim);
}
.task-content {
flex: 1;
min-width: 0;
}
.task-title {
display: block;
font-size: var(--text-sm);
color: var(--text-1);
line-height: var(--leading-snug);
word-break: break-word;
}
.task-title.priority-high {
color: var(--error);
}
.task-title.priority-med {
color: var(--warning);
}
.task-meta {
font-size: var(--text-xs);
color: var(--text-4);
margin-top: 1px;
}
.tasks-empty {
padding: var(--sp-6) 0;
text-align: center;
color: var(--text-3);
font-size: var(--text-sm);
}
.tasks-loading {
padding: var(--sp-3) 0;
}
.tasks-footer {
display: block;
text-align: center;
padding-top: var(--sp-3);
margin-top: var(--sp-3);
border-top: 1px solid var(--border);
font-size: var(--text-sm);
color: var(--accent);
text-decoration: none;
}
.tasks-footer:hover {
text-decoration: underline;
}
/* Mobile: hide the sidebar panel, show inline card instead */
@media (max-width: 1100px) {
.tasks-panel {
display: none;
}
}
</style>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { page } from '$app/state';
import { LayoutDashboard, DollarSign, Package, Activity, MoreVertical, MapPin, BookOpen, Library, Settings } from '@lucide/svelte';
import { LayoutDashboard, DollarSign, Package, Activity, MoreVertical, MapPin, BookOpen, Library, Settings, CheckSquare } from '@lucide/svelte';
interface Props {
visibleApps?: string[];
@@ -60,6 +60,12 @@
<div class="more-sheet-overlay open" onclick={(e) => { if (e.target === e.currentTarget) closeMore(); }} onkeydown={() => {}}>
<div class="more-sheet">
<div class="more-sheet-handle"></div>
{#if showApp('tasks')}
<a href="/tasks" class="more-sheet-item" onclick={closeMore}>
<CheckSquare size={20} />
Tasks
</a>
{/if}
{#if showApp('trips')}
<a href="/trips" class="more-sheet-item" onclick={closeMore}>
<MapPin size={20} />

View File

@@ -39,6 +39,10 @@
<div class="navbar-links">
<a href="/" class="navbar-link" class:active={page.url.pathname === '/'}>Dashboard</a>
{#if showApp('tasks')}
<a href="/tasks" class="navbar-link" class:active={isActive('/tasks')}>Tasks</a>
{/if}
{#if showApp('trips')}
<a href="/trips" class="navbar-link" class:active={isActive('/trips')}>Trips</a>
{/if}

View File

@@ -22,7 +22,7 @@ export const load: LayoutServerLoad = async ({ cookies, url }) => {
// Hides nav items but does NOT block direct URL access.
// This is intentional: all shared services are accessible to all authenticated users.
// Hiding reduces clutter for users who don't need certain apps day-to-day.
const allApps = ['trips', 'fitness', 'inventory', 'budget', 'reader', 'media'];
const allApps = ['tasks', 'trips', 'fitness', 'inventory', 'budget', 'reader', 'media'];
const hiddenByUser: Record<string, string[]> = {
'madiha': ['inventory', 'reader'],
};

View File

@@ -4,9 +4,11 @@
import DashboardActionCard from '$lib/components/dashboard/DashboardActionCard.svelte';
import BudgetModule from '$lib/components/dashboard/BudgetModule.svelte';
import FitnessModule from '$lib/components/dashboard/FitnessModule.svelte';
import IssuesModule from '$lib/components/dashboard/IssuesModule.svelte';
import TasksPanel from '$lib/components/dashboard/TasksPanel.svelte';
import TasksModule from '$lib/components/dashboard/TasksModule.svelte';
const userName = $derived((page as any).data?.user?.display_name || 'there');
import IssuesModule from '$lib/components/dashboard/IssuesModule.svelte';
let inventoryIssueCount = $state(0);
let inventoryReviewCount = $state(0);
@@ -58,7 +60,11 @@
});
</script>
<div class="page">
<div class="page dash-page">
<div class="dash-layout">
<aside class="tasks-sidebar">
<TasksPanel />
</aside>
<div class="app-surface">
<div class="page-header">
<div class="page-title">Dashboard</div>
@@ -90,6 +96,8 @@
/>
</div>
<TasksModule />
<div class="modules-grid">
<BudgetModule />
<div class="right-stack">
@@ -99,8 +107,50 @@
</div>
</div>
</div>
</div>
<style>
.dash-layout {
display: grid;
grid-template-columns: 280px 1fr;
gap: var(--module-gap);
max-width: 1500px;
margin: 0 auto;
padding: 0 var(--sp-6);
}
.tasks-sidebar {
position: sticky;
top: 80px;
align-self: start;
}
.dash-layout > :global(.app-surface) {
padding: 0;
max-width: none;
}
@media (max-width: 1100px) {
.dash-layout {
display: block;
padding: 0;
}
.tasks-sidebar {
display: none;
}
.dash-layout > :global(.app-surface) {
padding: 0 var(--sp-6);
max-width: 1200px;
margin: 0 auto;
}
}
@media (max-width: 768px) {
.dash-layout > :global(.app-surface) {
padding: 0 var(--sp-5);
}
}
.action-cards {
display: flex;
flex-direction: column;
@@ -126,5 +176,9 @@
.modules-grid {
grid-template-columns: 1fr;
}
.modules-grid > :global(*) {
min-width: 0;
max-width: 100%;
}
}
</style>

View File

@@ -0,0 +1,730 @@
<script lang="ts">
import { onMount } from 'svelte';
interface Project {
id: string;
name: string;
isInbox?: boolean;
isShared?: boolean;
}
interface Task {
id: string;
title: string;
content?: string;
projectId: string;
_projectName: string;
_projectId: string;
dueDate?: string;
startDate?: string;
isAllDay?: boolean;
priority: number;
status: number;
repeatFlag?: string;
}
type TabType = 'today' | 'all' | 'completed' | 'project';
let loading = $state(true);
let activeTab = $state<TabType>('today');
let tasks = $state<Task[]>([]);
let todayTasks = $state<Task[]>([]);
let overdueTasks = $state<Task[]>([]);
let projects = $state<Project[]>([]);
let selectedProjectId = $state('');
let completing = $state<Set<string>>(new Set());
let deleting = $state<Set<string>>(new Set());
// Add task form
let showAdd = $state(false);
let newTitle = $state('');
let newProjectId = $state('');
let newDueDate = $state('');
let newDueTime = $state('');
let newAllDay = $state(true);
let newPriority = $state(0);
let newRepeat = $state('');
let saving = $state(false);
// Edit
let editingTask = $state<Task | null>(null);
let editTitle = $state('');
let editDueDate = $state('');
let editDueTime = $state('');
let editAllDay = $state(true);
let editPriority = $state(0);
let editRepeat = $state('');
let editProjectId = $state('');
// New project
let showNewProject = $state(false);
let newProjectName = $state('');
async function api(path: string, opts: RequestInit = {}) {
const res = await fetch(`/api/tasks${path}`, { credentials: 'include', ...opts });
if (!res.ok) throw new Error(`${res.status}`);
return res.json();
}
function formatDate(task: Task): string {
const d = task.startDate || task.dueDate;
if (!d) return 'No date';
try {
const date = new Date(d);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const taskDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const diff = Math.round((taskDate.getTime() - today.getTime()) / 86400000);
let dateStr = '';
if (diff === 0) dateStr = 'Today';
else if (diff === 1) dateStr = 'Tomorrow';
else if (diff === -1) dateStr = 'Yesterday';
else if (diff < -1) dateStr = `${Math.abs(diff)}d ago`;
else dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
if (!task.isAllDay) {
const h = date.getHours();
const m = date.getMinutes();
if (h !== 0 || m !== 0) {
const ampm = h >= 12 ? 'PM' : 'AM';
const hour = h % 12 || 12;
const timeStr = m > 0 ? `${hour}:${String(m).padStart(2, '0')} ${ampm}` : `${hour} ${ampm}`;
return `${dateStr} · ${timeStr}`;
}
}
return dateStr;
} catch { return ''; }
}
function isOverdue(task: Task): boolean {
const d = task.startDate || task.dueDate;
if (!d) return false;
const taskDate = new Date(d);
const now = new Date();
return taskDate < new Date(now.getFullYear(), now.getMonth(), now.getDate()) && task.status === 0;
}
function priorityLabel(p: number): string {
if (p >= 5) return 'high';
if (p >= 3) return 'medium';
if (p >= 1) return 'low';
return '';
}
function repeatLabel(flag: string): string {
if (!flag) return '';
if (flag.includes('DAILY')) return 'Daily';
if (flag.includes('WEEKLY')) {
const m = flag.match(/BYDAY=([A-Z,]+)/);
if (m) return `Weekly (${m[1]})`;
return 'Weekly';
}
if (flag.includes('MONTHLY')) return 'Monthly';
if (flag.includes('YEARLY')) return 'Yearly';
return 'Repeating';
}
async function loadProjects() {
try {
const data = await api('/projects');
projects = data.projects || [];
if (projects.length > 0 && !newProjectId) {
newProjectId = projects[0].id;
}
} catch { /* silent */ }
}
async function loadToday() {
try {
const data = await api('/today');
todayTasks = data.today || [];
overdueTasks = data.overdue || [];
} catch { /* silent */ }
}
async function loadAll() {
try {
const data = await api('/tasks');
tasks = (data.tasks || []).filter((t: Task) => t.status === 0);
} catch { /* silent */ }
}
async function loadCompleted() {
try {
const data = await api('/tasks/completed');
tasks = data.tasks || [];
} catch { /* silent */ }
}
async function loadProject(projectId: string) {
try {
const data = await api(`/tasks?project_id=${projectId}`);
tasks = (data.tasks || []).filter((t: Task) => t.status === 0);
} catch { /* silent */ }
}
async function loadData() {
loading = true;
if (activeTab === 'today') await loadToday();
else if (activeTab === 'all') await loadAll();
else if (activeTab === 'completed') await loadCompleted();
else if (activeTab === 'project' && selectedProjectId) await loadProject(selectedProjectId);
loading = false;
}
async function addTask() {
if (!newTitle.trim() || !newProjectId) return;
saving = true;
try {
const payload: any = {
title: newTitle.trim(),
projectId: newProjectId,
isAllDay: newAllDay,
priority: newPriority,
};
if (newDueDate) {
if (newAllDay) {
payload.dueDate = `${newDueDate}T00:00:00+0000`;
} else {
payload.startDate = `${newDueDate}T${newDueTime || '09:00'}:00+0000`;
payload.dueDate = `${newDueDate}T${newDueTime || '09:00'}:00+0000`;
}
}
if (newRepeat) payload.repeatFlag = newRepeat;
await api('/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
newTitle = '';
newDueDate = '';
newDueTime = '';
newPriority = 0;
newRepeat = '';
showAdd = false;
await loadData();
} catch (e) { console.error('Failed to add task', e); }
saving = false;
}
async function completeTask(task: Task) {
completing = new Set([...completing, task.id]);
try {
await api(`/tasks/${task.id}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectId: task.projectId || task._projectId })
});
setTimeout(loadData, 400);
} catch {
completing = new Set([...completing].filter(id => id !== task.id));
}
}
async function deleteTask(task: Task) {
if (!confirm(`Delete "${task.title}"?`)) return;
deleting = new Set([...deleting, task.id]);
try {
await api(`/tasks/${task.id}?projectId=${task.projectId || task._projectId}`, {
method: 'DELETE',
});
setTimeout(loadData, 300);
} catch {
deleting = new Set([...deleting].filter(id => id !== task.id));
}
}
function startEdit(task: Task) {
editingTask = task;
editTitle = task.title;
editAllDay = task.isAllDay ?? true;
editPriority = task.priority || 0;
editRepeat = task.repeatFlag || '';
editProjectId = task.projectId || task._projectId;
const d = task.startDate || task.dueDate || '';
if (d) {
try {
const date = new Date(d);
editDueDate = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
editDueTime = `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
} catch { editDueDate = ''; editDueTime = ''; }
} else {
editDueDate = '';
editDueTime = '';
}
}
async function saveEdit() {
if (!editingTask || !editTitle.trim()) return;
saving = true;
try {
const payload: any = {
title: editTitle.trim(),
projectId: editProjectId,
isAllDay: editAllDay,
priority: editPriority,
repeatFlag: editRepeat || null,
};
if (editDueDate) {
if (editAllDay) {
payload.dueDate = `${editDueDate}T00:00:00+0000`;
payload.startDate = `${editDueDate}T00:00:00+0000`;
} else {
payload.startDate = `${editDueDate}T${editDueTime || '09:00'}:00+0000`;
payload.dueDate = `${editDueDate}T${editDueTime || '09:00'}:00+0000`;
}
} else {
payload.dueDate = null;
payload.startDate = null;
}
await api(`/tasks/${editingTask.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
editingTask = null;
await loadData();
} catch (e) { console.error('Failed to update task', e); }
saving = false;
}
async function createProject() {
if (!newProjectName.trim()) return;
try {
await api('/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newProjectName.trim() })
});
newProjectName = '';
showNewProject = false;
await loadProjects();
} catch (e) { console.error('Failed to create project', e); }
}
function selectProject(projectId: string) {
selectedProjectId = projectId;
activeTab = 'project';
loadData();
}
onMount(async () => {
await loadProjects();
await loadData();
});
</script>
<div class="page">
<div class="page-header">
<h1 class="page-title">Tasks</h1>
<div class="header-actions">
<button class="btn-secondary" onclick={() => { showNewProject = !showNewProject; }}>
{showNewProject ? 'Cancel' : '+ List'}
</button>
<button class="btn-primary" onclick={() => { showAdd = !showAdd; }}>
{showAdd ? 'Cancel' : '+ Add task'}
</button>
</div>
</div>
{#if showNewProject}
<div class="module add-form">
<div class="add-row">
<input class="input" style="flex:1" placeholder="New list name" bind:value={newProjectName}
onkeydown={(e) => e.key === 'Enter' && createProject()} />
<button class="btn-primary" onclick={createProject} disabled={!newProjectName.trim()}>Create</button>
</div>
</div>
{/if}
{#if showAdd}
<div class="module add-form">
<input class="input" placeholder="What needs to be done?" bind:value={newTitle}
onkeydown={(e) => e.key === 'Enter' && addTask()} />
<div class="add-row">
<select class="input add-select" bind:value={newProjectId}>
{#each projects as proj}
<option value={proj.id}>{proj.name}</option>
{/each}
</select>
<input class="input add-date" type="date" bind:value={newDueDate} />
{#if !newAllDay}
<input class="input add-time" type="time" bind:value={newDueTime} />
{/if}
</div>
<div class="add-row">
<label class="add-toggle">
<input type="checkbox" bind:checked={newAllDay} /> All day
</label>
<select class="input add-priority" bind:value={newPriority}>
<option value={0}>No priority</option>
<option value={1}>Low</option>
<option value={3}>Medium</option>
<option value={5}>High</option>
</select>
<select class="input add-repeat" bind:value={newRepeat}>
<option value="">No repeat</option>
<option value="FREQ=DAILY">Daily</option>
<option value="FREQ=WEEKLY">Weekly</option>
<option value="FREQ=WEEKLY;BYDAY=MO,WE,FR">Mon/Wed/Fri</option>
<option value="FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR">Weekdays</option>
<option value="FREQ=MONTHLY">Monthly</option>
<option value="FREQ=YEARLY">Yearly</option>
</select>
</div>
<button class="btn-primary full" onclick={addTask} disabled={saving || !newTitle.trim()}>
{saving ? 'Adding...' : 'Add task'}
</button>
</div>
{/if}
<div class="tab-bar tasks-tabs">
<button class="tab" class:active={activeTab === 'today'} onclick={() => { activeTab = 'today'; loadData(); }}>Today</button>
<button class="tab" class:active={activeTab === 'all'} onclick={() => { activeTab = 'all'; loadData(); }}>All</button>
{#each projects as proj}
<button class="tab" class:active={activeTab === 'project' && selectedProjectId === proj.id} onclick={() => selectProject(proj.id)}>
{proj.name}
</button>
{/each}
<button class="tab" class:active={activeTab === 'completed'} onclick={() => { activeTab = 'completed'; loadData(); }}>Done</button>
</div>
{#if loading}
<div class="module"><div class="skeleton" style="height: 200px"></div></div>
{:else}
{#if activeTab === 'today'}
{#if overdueTasks.length > 0}
<div class="section-label overdue-label">Overdue · {overdueTasks.length}</div>
<div class="module flush">
{#each overdueTasks as task (task.id)}
<div class="data-row task-item" class:fading={completing.has(task.id) || deleting.has(task.id)}>
<button class="task-check" onclick={() => completeTask(task)}>
<svg width="22" height="22" viewBox="0 0 22 22" fill="none">
<rect x="1.5" y="1.5" width="19" height="19" rx="4" stroke="var(--error)" stroke-width="2"/>
</svg>
</button>
<div class="task-body" onclick={() => startEdit(task)}>
<div class="task-name">{task.title}</div>
<div class="task-sub">
{formatDate(task)} · {task._projectName}
{#if task.repeatFlag}<span class="badge muted">{repeatLabel(task.repeatFlag)}</span>{/if}
</div>
</div>
<button class="btn-icon task-delete" onclick={() => deleteTask(task)} title="Delete">×</button>
</div>
{/each}
</div>
{/if}
{#if todayTasks.length > 0}
<div class="section-label">Today · {todayTasks.length}</div>
<div class="module flush">
{#each todayTasks as task (task.id)}
<div class="data-row task-item" class:fading={completing.has(task.id) || deleting.has(task.id)}>
<button class="task-check" onclick={() => completeTask(task)}>
<svg width="22" height="22" viewBox="0 0 22 22" fill="none">
<rect x="1.5" y="1.5" width="19" height="19" rx="4" stroke="var(--border-strong)" stroke-width="2"/>
</svg>
</button>
<div class="task-body" onclick={() => startEdit(task)}>
<div class="task-name">{task.title}</div>
<div class="task-sub">
{formatDate(task)} · {task._projectName}
{#if task.repeatFlag}<span class="badge muted">{repeatLabel(task.repeatFlag)}</span>{/if}
</div>
</div>
<button class="btn-icon task-delete" onclick={() => deleteTask(task)} title="Delete">×</button>
</div>
{/each}
</div>
{/if}
{#if todayTasks.length === 0 && overdueTasks.length === 0}
<div class="module">
<div class="empty-state">All clear for today</div>
</div>
{/if}
{:else if activeTab === 'completed'}
{#if tasks.length > 0}
<div class="module flush">
{#each tasks as task (task.id)}
<div class="data-row task-item completed-item">
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" style="flex-shrink:0">
<rect x="1.5" y="1.5" width="19" height="19" rx="4" stroke="var(--success)" stroke-width="2" fill="var(--success-dim)"/>
<polyline points="7,11 10,14 15,8" stroke="var(--success)" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<div class="task-body">
<div class="task-name completed-name">{task.title}</div>
<div class="task-sub">{task._projectName}{#if task.completedAt} · {new Date(task.completedAt).toLocaleDateString()}{/if}</div>
</div>
</div>
{/each}
</div>
{:else}
<div class="module"><div class="empty-state">No completed tasks</div></div>
{/if}
{:else}
{#if tasks.length > 0}
<div class="module flush">
{#each tasks as task (task.id)}
<div class="data-row task-item" class:fading={completing.has(task.id) || deleting.has(task.id)}>
<button class="task-check" onclick={() => completeTask(task)}>
<svg width="22" height="22" viewBox="0 0 22 22" fill="none">
<rect x="1.5" y="1.5" width="19" height="19" rx="4" stroke="{isOverdue(task) ? 'var(--error)' : 'var(--border-strong)'}" stroke-width="2"/>
</svg>
</button>
<div class="task-body" onclick={() => startEdit(task)}>
<div class="task-name" class:overdue-text={isOverdue(task)}>{task.title}</div>
<div class="task-sub">
{formatDate(task)}
{#if activeTab === 'all'} · {task._projectName}{/if}
{#if task.repeatFlag}<span class="badge muted">{repeatLabel(task.repeatFlag)}</span>{/if}
{#if priorityLabel(task.priority)}
<span class="badge {priorityLabel(task.priority) === 'high' ? 'error' : priorityLabel(task.priority) === 'medium' ? 'warning' : 'muted'}">{priorityLabel(task.priority)}</span>
{/if}
</div>
</div>
<button class="btn-icon task-delete" onclick={() => deleteTask(task)} title="Delete">×</button>
</div>
{/each}
</div>
{:else}
<div class="module">
<div class="empty-state">No tasks</div>
</div>
{/if}
{/if}
{/if}
</div>
<!-- Edit modal -->
{#if editingTask}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={(e) => { if (e.target === e.currentTarget) editingTask = null; }} onkeydown={() => {}}>
<div class="modal">
<div class="modal-header">
<h3 class="modal-title">Edit task</h3>
<button class="btn-icon" onclick={() => editingTask = null}>×</button>
</div>
<div class="modal-body">
<input class="input" bind:value={editTitle} placeholder="Task title" />
<select class="input" bind:value={editProjectId}>
{#each projects as proj}
<option value={proj.id}>{proj.name}</option>
{/each}
</select>
<input class="input" type="date" bind:value={editDueDate} />
<label class="add-toggle">
<input type="checkbox" bind:checked={editAllDay} /> All day
</label>
{#if !editAllDay}
<input class="input" type="time" bind:value={editDueTime} />
{/if}
<select class="input" bind:value={editPriority}>
<option value={0}>No priority</option>
<option value={1}>Low</option>
<option value={3}>Medium</option>
<option value={5}>High</option>
</select>
<select class="input" bind:value={editRepeat}>
<option value="">No repeat</option>
<option value="FREQ=DAILY">Daily</option>
<option value="FREQ=WEEKLY">Weekly</option>
<option value="FREQ=WEEKLY;BYDAY=MO,WE,FR">Mon/Wed/Fri</option>
<option value="FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR">Weekdays</option>
<option value="FREQ=MONTHLY">Monthly</option>
<option value="FREQ=YEARLY">Yearly</option>
</select>
</div>
<div class="modal-actions">
<button class="btn-secondary" onclick={() => editingTask = null}>Cancel</button>
<button class="btn-primary" onclick={saveEdit} disabled={saving}>{saving ? 'Saving...' : 'Save'}</button>
</div>
</div>
</div>
{/if}
<style>
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.header-actions {
display: flex;
gap: var(--sp-2);
}
.tasks-tabs {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
padding-bottom: var(--sp-2);
margin-bottom: var(--sp-3);
gap: var(--sp-2);
}
.tasks-tabs::-webkit-scrollbar { display: none; }
.tasks-tabs > :global(.tab) {
white-space: nowrap;
flex-shrink: 0;
padding: var(--sp-1.5) var(--sp-3);
font-size: var(--text-sm);
border: 1px solid var(--border);
}
.tasks-tabs > :global(.tab.active) {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.add-form {
display: flex;
flex-direction: column;
gap: var(--sp-3);
margin-bottom: var(--section-gap);
}
.add-row {
display: flex;
gap: var(--sp-2);
flex-wrap: wrap;
align-items: center;
}
.add-select { flex: 1; min-width: 120px; }
.add-date { width: 160px; }
.add-time { width: 120px; }
.add-priority { width: 130px; }
.add-repeat { width: 150px; }
.add-toggle {
font-size: var(--text-sm);
color: var(--text-2);
display: flex;
align-items: center;
gap: var(--sp-1);
cursor: pointer;
}
.task-item {
display: flex;
align-items: flex-start;
gap: var(--sp-3);
transition: opacity 0.3s;
}
.task-item.fading { opacity: 0.2; }
.task-check {
flex-shrink: 0;
padding: 0;
margin-top: 2px;
background: none;
border: none;
cursor: pointer;
}
.task-check:hover svg rect {
stroke: var(--accent);
fill: var(--accent-dim);
}
.task-body {
flex: 1;
min-width: 0;
cursor: pointer;
}
.task-name {
font-size: var(--text-base);
color: var(--text-1);
line-height: var(--leading-snug);
}
.task-name.overdue-text { color: var(--error); }
.task-name.completed-name {
text-decoration: line-through;
color: var(--text-3);
}
.completed-item { opacity: 0.7; }
.task-sub {
font-size: var(--text-sm);
color: var(--text-3);
margin-top: 2px;
display: flex;
align-items: center;
gap: var(--sp-2);
flex-wrap: wrap;
}
.task-delete {
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s;
color: var(--text-4);
font-size: var(--text-lg);
}
.task-item:hover .task-delete { opacity: 1; }
.overdue-label { color: var(--error) !important; }
.empty-state {
padding: var(--sp-8);
text-align: center;
color: var(--text-3);
font-size: var(--text-base);
}
.modal-overlay {
position: fixed;
inset: 0;
background: var(--overlay);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal {
background: var(--card);
border-radius: var(--radius);
box-shadow: var(--shadow-xl);
width: 90%;
max-width: 420px;
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--card-pad) var(--card-pad) 0;
}
.modal-title {
font-size: var(--text-lg);
font-weight: 600;
color: var(--text-1);
}
.modal-body {
padding: var(--sp-4) var(--card-pad);
display: flex;
flex-direction: column;
gap: var(--sp-3);
}
.modal-actions {
display: flex;
gap: var(--sp-2);
justify-content: flex-end;
padding: 0 var(--card-pad) var(--card-pad);
}
</style>

View File

@@ -21,10 +21,12 @@ TRIPS_API_TOKEN = os.environ.get("TRIPS_API_TOKEN", "")
SHELFMARK_URL = os.environ.get("SHELFMARK_URL", "http://shelfmark:8084")
SPOTIZERR_URL = os.environ.get("SPOTIZERR_URL", "http://spotizerr-app:7171")
BUDGET_URL = os.environ.get("BUDGET_BACKEND_URL", "http://localhost:3001")
TASKS_URL = os.environ.get("TASKS_BACKEND_URL", "http://tasks-service:8098")
# ── Service API keys (for internal service auth) ──
INVENTORY_SERVICE_API_KEY = os.environ.get("INVENTORY_SERVICE_API_KEY", "")
BUDGET_SERVICE_API_KEY = os.environ.get("BUDGET_SERVICE_API_KEY", "")
TASKS_SERVICE_API_KEY = os.environ.get("TASKS_SERVICE_API_KEY", "")
# ── Booklore (book library manager) ──
BOOKLORE_URL = os.environ.get("BOOKLORE_URL", "http://booklore:6060")

View File

@@ -188,7 +188,7 @@ def handle_dashboard(handler, user):
conn.close()
# Services that use gateway-injected API keys (not per-user tokens)
GATEWAY_KEY_SERVICES = {"inventory", "reader", "books", "music", "budget"}
GATEWAY_KEY_SERVICES = {"inventory", "reader", "books", "music", "budget", "tasks"}
widgets = []
futures = {}

View File

@@ -8,7 +8,7 @@ import bcrypt
from config import (
DB_PATH, TRIPS_URL, FITNESS_URL, INVENTORY_URL,
MINIFLUX_URL, SHELFMARK_URL, SPOTIZERR_URL, BUDGET_URL,
MINIFLUX_URL, SHELFMARK_URL, SPOTIZERR_URL, BUDGET_URL, TASKS_URL,
)
@@ -122,6 +122,13 @@ def init_db():
conn.commit()
print("[Gateway] Added budget app")
# Ensure tasks app exists
tasks = c.execute("SELECT id FROM apps WHERE id = 'tasks'").fetchone()
if not tasks:
c.execute("INSERT INTO apps VALUES ('tasks', 'Tasks', 'check-square', '/tasks', ?, 8, 1, 'today_tasks')", (TASKS_URL,))
conn.commit()
print("[Gateway] Added tasks app")
# Seed admin user from env vars if no users exist
import os
user_count = c.execute("SELECT COUNT(*) FROM users").fetchone()[0]

View File

@@ -283,7 +283,7 @@ class GatewayHandler(ResponseMixin, BaseHTTPRequestHandler):
self._send_json({"error": "Unknown service"}, 404)
return
from config import MINIFLUX_API_KEY, INVENTORY_SERVICE_API_KEY, BUDGET_SERVICE_API_KEY
from config import MINIFLUX_API_KEY, INVENTORY_SERVICE_API_KEY, BUDGET_SERVICE_API_KEY, TASKS_SERVICE_API_KEY
headers = {}
ct = self.headers.get("Content-Type")
if ct:
@@ -298,6 +298,11 @@ class GatewayHandler(ResponseMixin, BaseHTTPRequestHandler):
headers["X-API-Key"] = INVENTORY_SERVICE_API_KEY
elif service_id == "budget" and BUDGET_SERVICE_API_KEY:
headers["X-API-Key"] = BUDGET_SERVICE_API_KEY
elif service_id == "tasks":
# Inject user identity for the task manager
if user:
headers["X-Gateway-User-Id"] = str(user["id"])
headers["X-Gateway-User-Name"] = user.get("display_name", user.get("username", ""))
elif user:
svc_token = get_service_token(user["id"], service_id)
if svc_token:

37
mobile/BUILD_IOS.md Normal file
View File

@@ -0,0 +1,37 @@
# Building Second Brain for iOS
## One-time setup (on your MacBook)
1. Install Xcode from the App Store (if not already installed)
2. Open Xcode once and accept the license agreement
3. Install Xcode command line tools: `xcode-select --install`
4. Install CocoaPods: `sudo gem install cocoapods`
## Copy project to Mac
Copy the `mobile/` folder to your MacBook:
```bash
scp -r yusiboyz@192.168.1.42:/media/yusiboyz/Media/Scripts/platform/mobile ~/Desktop/SecondBrain
```
Or use any file transfer method (AirDrop, USB, etc.)
## Build and install
```bash
cd ~/Desktop/SecondBrain
npm install
npx cap sync ios
npx cap open ios
```
This opens Xcode. Then:
1. Select your iPhone from the device dropdown (top of Xcode)
2. Click the Play button (or Cmd+R)
3. First time: Xcode will ask to trust your Apple ID — go to iPhone Settings > General > VPN & Device Management and trust the developer certificate
4. The app installs and launches!
## After web changes
No rebuild needed — the app loads from dash.quadjourney.com live.
Only rebuild if you change native plugins or the icon.

View File

@@ -0,0 +1,39 @@
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'com.quadjourney.secondbrain',
appName: 'Second Brain',
webDir: 'www',
server: {
// Live mode — loads from your deployed site
url: 'https://dash.quadjourney.com',
cleartext: false,
},
ios: {
contentInset: 'automatic',
preferredContentMode: 'mobile',
scheme: 'Second Brain',
backgroundColor: '#09090b',
},
plugins: {
SplashScreen: {
launchAutoHide: true,
launchShowDuration: 1500,
backgroundColor: '#09090b',
showSpinner: false,
},
StatusBar: {
style: 'DARK',
backgroundColor: '#09090b',
},
Keyboard: {
resize: 'body',
resizeOnFullScreen: true,
},
},
};
export default config;

23
mobile/icon.svg Normal file
View File

@@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#4F46E5"/>
<stop offset="100%" stop-color="#3B82F6"/>
</linearGradient>
</defs>
<rect width="1024" height="1024" rx="224" fill="url(#bg)"/>
<g transform="translate(512, 512) scale(1)" fill="none" stroke="white" stroke-linecap="round" stroke-linejoin="round">
<!-- Brain - left half -->
<path d="M-20 180 C-20 180 -140 160 -180 80 C-200 30 -190 -20 -160 -60 C-185 -90 -180 -140 -150 -170 C-120 -200 -80 -210 -50 -195 C-40 -230 -5 -250 30 -240 C55 -232 65 -210 65 -190" stroke-width="32"/>
<!-- Brain - right half -->
<path d="M20 180 C20 180 140 160 180 80 C200 30 190 -20 160 -60 C185 -90 180 -140 150 -170 C120 -200 80 -210 50 -195 C40 -230 5 -250 -30 -240 C-55 -232 -65 -210 -65 -190" stroke-width="32"/>
<!-- Center line -->
<line x1="0" y1="-200" x2="0" y2="180" stroke-width="28"/>
<!-- Left fold -->
<path d="M-170 20 C-120 10 -60 15 0 5" stroke-width="24"/>
<!-- Right fold -->
<path d="M170 20 C120 10 60 15 0 5" stroke-width="24"/>
<!-- Brain stem -->
<path d="M0 180 L0 240 C0 270 -20 280 -40 270" stroke-width="28"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

13
mobile/ios/.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
App/build
App/Pods
App/output
App/App/public
DerivedData
xcuserdata
# Cordova plugins for Capacitor
capacitor-cordova-ios-plugins
# Generated Config files
App/App/capacitor.config.json
App/App/config.xml

View File

@@ -0,0 +1,376 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 60;
objects = {
/* Begin PBXBuildFile section */
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; };
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; };
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
504EC3011FED79650016851F /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
504EC2FB1FED79650016851F = {
isa = PBXGroup;
children = (
958DCC722DB07C7200EA8C5F /* debug.xcconfig */,
504EC3061FED79650016851F /* App */,
504EC3051FED79650016851F /* Products */,
);
sourceTree = "<group>";
};
504EC3051FED79650016851F /* Products */ = {
isa = PBXGroup;
children = (
504EC3041FED79650016851F /* App.app */,
);
name = Products;
sourceTree = "<group>";
};
504EC3061FED79650016851F /* App */ = {
isa = PBXGroup;
children = (
50379B222058CBB4000EE86E /* capacitor.config.json */,
504EC3071FED79650016851F /* AppDelegate.swift */,
504EC30B1FED79650016851F /* Main.storyboard */,
504EC30E1FED79650016851F /* Assets.xcassets */,
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
504EC3131FED79650016851F /* Info.plist */,
2FAD9762203C412B000D30F8 /* config.xml */,
50B271D01FEDC1A000F3C39B /* public */,
);
path = App;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
504EC3031FED79650016851F /* App */ = {
isa = PBXNativeTarget;
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */;
buildPhases = (
504EC3001FED79650016851F /* Sources */,
504EC3011FED79650016851F /* Frameworks */,
504EC3021FED79650016851F /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = App;
packageProductDependencies = (
4D22ABE82AF431CB00220026 /* CapApp-SPM */,
);
productName = App;
productReference = 504EC3041FED79650016851F /* App.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
504EC2FC1FED79650016851F /* Project object */ = {
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 0920;
LastUpgradeCheck = 0920;
TargetAttributes = {
504EC3031FED79650016851F = {
CreatedOnToolsVersion = 9.2;
LastSwiftMigration = 1100;
ProvisioningStyle = Automatic;
};
};
};
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
compatibilityVersion = "Xcode 8.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 504EC2FB1FED79650016851F;
packageReferences = (
D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */,
);
productRefGroup = 504EC3051FED79650016851F /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
504EC3031FED79650016851F /* App */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
504EC3021FED79650016851F /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */,
50B271D11FEDC1A000F3C39B /* public in Resources */,
504EC30F1FED79650016851F /* Assets.xcassets in Resources */,
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
504EC3001FED79650016851F /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXVariantGroup section */
504EC30B1FED79650016851F /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
504EC30C1FED79650016851F /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
504EC3101FED79650016851F /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
504EC3111FED79650016851F /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
504EC3141FED79650016851F /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_IDENTITY = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
504EC3151FED79650016851F /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_IDENTITY = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
504EC3171FED79650016851F /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = com.quadjourney.secondbrain;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
504EC3181FED79650016851F /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.quadjourney.secondbrain;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = {
isa = XCConfigurationList;
buildConfigurations = (
504EC3141FED79650016851F /* Debug */,
504EC3151FED79650016851F /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = {
isa = XCConfigurationList;
buildConfigurations = (
504EC3171FED79650016851F /* Debug */,
504EC3181FED79650016851F /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = "CapApp-SPM";
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
4D22ABE82AF431CB00220026 /* CapApp-SPM */ = {
isa = XCSwiftPackageProductDependency;
package = D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */;
productName = "CapApp-SPM";
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 504EC2FC1FED79650016851F /* Project object */;
}

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,49 @@
import UIKit
import Capacitor
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
return true
}
func applicationWillResignActive(_ application: UIApplication) {
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
}
func applicationDidEnterBackground(_ application: UIApplication) {
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
}
func applicationWillEnterForeground(_ application: UIApplication) {
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
}
func applicationDidBecomeActive(_ application: UIApplication) {
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
}
func applicationWillTerminate(_ application: UIApplication) {
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
}
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
// Called when the app was launched with a url. Feel free to add additional processing here,
// but if you want the App API to support tracking app url opens, make sure to keep this call
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
}
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
// Called when the app was launched with an activity, including Universal Links.
// Feel free to add additional processing here, but if you want the App API to support
// tracking app url opens, make sure to keep this call
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
}
}

View File

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "AppIcon-512@2x.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
}
}

View File

@@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "splash-2732x2732-2.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "splash-2732x2732-1.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "splash-2732x2732.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17132" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17105"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<imageView key="view" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Splash" id="snD-IY-ifK">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
</imageView>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="Splash" width="1366" height="1366"/>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14111" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
<device id="retina4_7" orientation="portrait">
<adaptation id="fullscreen"/>
</device>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14088"/>
</dependencies>
<scenes>
<!--Bridge View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CAPACITOR_DEBUG</key>
<string>$(CAPACITOR_DEBUG)</string>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Second Brain</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
</dict>
</plist>

9
mobile/ios/App/CapApp-SPM/.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View File

@@ -0,0 +1,33 @@
// swift-tools-version: 5.9
import PackageDescription
// DO NOT MODIFY THIS FILE - managed by Capacitor CLI commands
let package = Package(
name: "CapApp-SPM",
platforms: [.iOS(.v15)],
products: [
.library(
name: "CapApp-SPM",
targets: ["CapApp-SPM"])
],
dependencies: [
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.3.0"),
.package(name: "CapacitorHaptics", path: "../../../node_modules/@capacitor/haptics"),
.package(name: "CapacitorKeyboard", path: "../../../node_modules/@capacitor/keyboard"),
.package(name: "CapacitorSplashScreen", path: "../../../node_modules/@capacitor/splash-screen"),
.package(name: "CapacitorStatusBar", path: "../../../node_modules/@capacitor/status-bar")
],
targets: [
.target(
name: "CapApp-SPM",
dependencies: [
.product(name: "Capacitor", package: "capacitor-swift-pm"),
.product(name: "Cordova", package: "capacitor-swift-pm"),
.product(name: "CapacitorHaptics", package: "CapacitorHaptics"),
.product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"),
.product(name: "CapacitorSplashScreen", package: "CapacitorSplashScreen"),
.product(name: "CapacitorStatusBar", package: "CapacitorStatusBar")
]
)
]
)

View File

@@ -0,0 +1,5 @@
# CapApp-SPM
This package is used to host SPM dependencies for your Capacitor project
Do not modify the contents of it or there may be unintended consequences.

View File

@@ -0,0 +1 @@
public let isCapacitorApp = true

View File

@@ -0,0 +1 @@
CAPACITOR_DEBUG = true

1115
mobile/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
mobile/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "mobile",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@capacitor/cli": "^8.3.0",
"@capacitor/core": "^8.3.0",
"@capacitor/haptics": "^8.0.2",
"@capacitor/ios": "^8.3.0",
"@capacitor/keyboard": "^8.0.2",
"@capacitor/splash-screen": "^8.0.1",
"@capacitor/status-bar": "^8.0.2"
}
}

19
mobile/www/index.html Normal file
View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Second Brain</title>
<style>
body { margin: 0; background: #09090b; display: flex; align-items: center; justify-content: center; height: 100vh; font-family: -apple-system, sans-serif; }
.loading { color: #a1a1aa; font-size: 16px; }
</style>
</head>
<body>
<div class="loading">Loading...</div>
<script>
// Redirect to live site (fallback if server.url doesn't work)
window.location.href = 'https://dash.quadjourney.com';
</script>
</body>
</html>

View File

@@ -0,0 +1,104 @@
Platform Security & Readiness Remediation — Final Status
=========================================================
Date: 2026-03-29
ISSUE TRACKER: Gitea yusiboyz/platform Issues #1#10
COMPLETED ISSUES
================
#2 Auth Boundary: Registration and Default Credentials
- /api/auth/register disabled (403)
- Gateway admin seeded from ADMIN_USERNAME/ADMIN_PASSWORD env vars only
- Trips USERNAME/PASSWORD have no default fallback
- Fitness user seed requires env vars (no "changeme" default)
- All passwords use bcrypt
#3 Trips Sharing Security
- handle_share_api enforces password via X-Share-Password header + bcrypt
- share_password stored as bcrypt hash
- All plaintext password logging removed
- Existing plaintext passwords invalidated by migration
- Dead hash_password function removed
#4 Fitness Authorization
- All user_id query params enforced to authenticated user's own ID
- /api/users returns only current user
- Wildcard CORS removed
#5 Gateway Trust Model
- Inventory and budget require API keys (X-API-Key middleware)
- Token validation uses protected endpoints per service type
- /debug-nocodb removed from inventory
- /test removed from inventory
- NocoDB search filter sanitized (strips operator injection chars)
- SERVICE_LEVEL_AUTH renamed to GATEWAY_KEY_SERVICES
- Trust model documented in docs/trust-model.md
- Per-user vs gateway-key services clearly distinguished
- Known limitations documented (no per-user isolation on shared services)
#6 Repository Hygiene
- No .env or .db files tracked in git
- .gitignore covers: .env*, *.db*, services/**/.env, data/, test-results/
- .env.example updated with all current env vars (no secrets)
#7 Transport Security
- Gateway: _internal_ssl_ctx removed entirely (internal services use plain HTTP)
- Gateway: ssl import removed from config.py
- Gateway: proxy.py uses urlopen() without context parameter
- Gateway: logout cookie includes HttpOnly, Secure, SameSite=Lax
- Gateway: image proxy uses default TLS + domain allowlist + content-type validation
- Trips: all 5 CERT_NONE sites removed (OpenAI, Gemini, Google Places, Geocode)
- Inventory: permissive cors() removed
- Budget: permissive cors() removed
#9 Performance Hardening
- Inventory /issues: server-side NocoDB WHERE filter (no full scan)
- Inventory /needs-review-count: server-side filter + pageInfo.totalRows
- Budget /summary: 1-minute cache
- Budget /transactions/recent: 30-second cache
- Budget /uncategorized-count: 2-minute cache
- Budget buildLookups: 2-minute cache
- Gateway /api/dashboard: 30-second per-user cache
- Actual Budget per-account API constraint documented
#10 Deployment Hardening
- All 6 containers run as non-root (appuser/node)
- Health checks on gateway, trips, fitness, inventory, budget, frontend
- PYTHONUNBUFFERED=1 on all Python services
- Trips Dockerfile only copies server.py (not whole context)
- Frontend uses multi-stage build
PARTIAL ISSUES
==============
#8 Dependency Security
- Budget path-to-regexp vulnerability fixed
- .gitea/workflows/security.yml committed:
- dependency-audit (npm audit for budget + frontend)
- secret-scanning (tracked .env/.db, hardcoded patterns)
- dockerfile-lint (USER instruction, HEALTHCHECK)
- Runner dependency documented in .gitea/README.md
- BLOCKED: Requires Gitea Actions runner to be configured operationally
OTHER FIXES (not tied to specific issues)
- Disconnect confirmation dialog added to Settings
- App nav visibility documented as cosmetic-only
- Stale /test startup log removed from inventory
- Frontend cookie vulnerability (4 low-severity) documented as not safe to fix
(requires breaking @sveltejs/kit downgrade)
MANUAL OPS ACTIONS REQUIRED
============================
1. Configure a Gitea Actions runner to activate CI workflows
2. Store admin password securely (set via ADMIN_PASSWORD env var)
3. Clean up local untracked .env files with real credentials if needed
4. Monitor @sveltejs/kit for a non-breaking cookie fix in future releases
ARCHITECTURE REFERENCE
======================
- Trust model: docs/trust-model.md
- CI workflows: .gitea/workflows/security.yml
- Runner setup: .gitea/README.md
- Design system: frontend-v2/DESIGN_SYSTEM.md
- Env var reference: .env.example

View File

@@ -4,7 +4,6 @@ if (typeof globalThis.navigator === 'undefined') {
}
const express = require('express');
const cors = require('cors');
const api = require('@actual-app/api');
const app = express();

View File

@@ -1,5 +1,4 @@
const express = require('express');
const cors = require('cors');
const multer = require('multer');
const axios = require('axios');
const FormData = require('form-data');

16
services/tasks/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM python:3.12-slim
WORKDIR /app
RUN adduser --disabled-password --no-create-home appuser
RUN mkdir -p /app/data && chown -R appuser /app/data
COPY --chown=appuser server.py .
EXPOSE 8098
ENV PYTHONUNBUFFERED=1
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD python3 -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8098/health', timeout=3)" || exit 1
USER appuser
CMD ["python3", "server.py"]

View File

@@ -0,0 +1,176 @@
"""One-time migration: pull all TickTick tasks into the custom task manager SQLite DB."""
import json
import os
import sqlite3
import uuid
import urllib.request
from pathlib import Path
TICKTICK_TOKEN = os.environ.get("TICKTICK_ACCESS_TOKEN", "")
# Handle JSON-wrapped token
try:
parsed = json.loads(TICKTICK_TOKEN)
TICKTICK_TOKEN = parsed.get("access_token", TICKTICK_TOKEN)
except (json.JSONDecodeError, TypeError):
pass
TICKTICK_BASE = "https://api.ticktick.com/open/v1"
DB_PATH = Path(os.environ.get("DB_PATH", "/app/data/tasks.db"))
GATEWAY_USER_ID = os.environ.get("GATEWAY_USER_ID", "3") # Yusuf's gateway user ID
GATEWAY_USER_NAME = os.environ.get("GATEWAY_USER_NAME", "Yusuf")
def tt_request(path):
url = f"{TICKTICK_BASE}/{path.lstrip('/')}"
req = urllib.request.Request(url)
req.add_header("Authorization", f"Bearer {TICKTICK_TOKEN}")
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read())
def get_db():
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("PRAGMA journal_mode = WAL")
return conn
def migrate():
if not TICKTICK_TOKEN:
print("ERROR: TICKTICK_ACCESS_TOKEN not set")
return
conn = get_db()
c = conn.cursor()
# Ensure user exists
existing = c.execute("SELECT id FROM users WHERE id = ?", (GATEWAY_USER_ID,)).fetchone()
if not existing:
c.execute("INSERT INTO users (id, username, display_name) VALUES (?, ?, ?)",
(GATEWAY_USER_ID, GATEWAY_USER_NAME.lower(), GATEWAY_USER_NAME))
conn.commit()
# Ensure Inbox exists
inbox = c.execute("SELECT id FROM projects WHERE user_id = ? AND is_inbox = 1", (GATEWAY_USER_ID,)).fetchone()
if not inbox:
inbox_id = str(uuid.uuid4())
c.execute("INSERT INTO projects (id, user_id, name, is_inbox, sort_order) VALUES (?, ?, 'Inbox', 1, -1)",
(inbox_id, GATEWAY_USER_ID))
conn.commit()
inbox = c.execute("SELECT id FROM projects WHERE user_id = ? AND is_inbox = 1", (GATEWAY_USER_ID,)).fetchone()
inbox_id = inbox["id"]
# Fetch TickTick projects
print("Fetching TickTick projects...")
tt_projects = tt_request("/project")
print(f" Found {len(tt_projects)} projects")
# Map TickTick project IDs to our project IDs
project_map = {} # tt_project_id -> our_project_id
for tp in tt_projects:
tt_id = tp["id"]
name = tp.get("name", "Untitled")
# Check if we already migrated this project (by name match)
existing_proj = c.execute("SELECT id FROM projects WHERE user_id = ? AND name = ? AND is_inbox = 0",
(GATEWAY_USER_ID, name)).fetchone()
if existing_proj:
project_map[tt_id] = existing_proj["id"]
print(f" Project '{name}' already exists, skipping creation")
else:
new_id = str(uuid.uuid4())
is_shared = 1 if any(kw in name.lower() for kw in ["family", "shared"]) else 0
c.execute("INSERT INTO projects (id, user_id, name, is_shared, sort_order) VALUES (?, ?, ?, ?, ?)",
(new_id, GATEWAY_USER_ID, name, is_shared, tp.get("sortOrder", 0)))
project_map[tt_id] = new_id
print(f" Created project '{name}' (shared={is_shared})")
conn.commit()
# Fetch all tasks from each project + inbox
all_tasks = []
# Inbox
print("Fetching Inbox tasks...")
try:
inbox_data = tt_request("/project/inbox/data")
inbox_tasks = inbox_data.get("tasks", [])
for t in inbox_tasks:
t["_our_project_id"] = inbox_id
t["_project_name"] = "Inbox"
all_tasks.extend(inbox_tasks)
print(f" Inbox: {len(inbox_tasks)} tasks")
except Exception as e:
print(f" Inbox error: {e}")
# Other projects
for tp in tt_projects:
tt_id = tp["id"]
name = tp.get("name", "?")
try:
data = tt_request(f"/project/{tt_id}/data")
tasks = data.get("tasks", [])
for t in tasks:
t["_our_project_id"] = project_map.get(tt_id, inbox_id)
t["_project_name"] = name
all_tasks.extend(tasks)
print(f" {name}: {len(tasks)} tasks")
except Exception as e:
print(f" {name} error: {e}")
print(f"\nTotal tasks to migrate: {len(all_tasks)}")
# Insert tasks
migrated = 0
skipped = 0
for t in all_tasks:
title = t.get("title", "").strip()
if not title:
skipped += 1
continue
# Check for duplicate by title + project
existing_task = c.execute(
"SELECT id FROM tasks WHERE title = ? AND project_id = ? AND user_id = ?",
(title, t["_our_project_id"], GATEWAY_USER_ID)).fetchone()
if existing_task:
skipped += 1
continue
task_id = str(uuid.uuid4())
status = t.get("status", 0)
completed_at = None
if status != 0:
completed_at = t.get("completedTime") or t.get("modifiedTime")
c.execute("""INSERT INTO tasks (id, project_id, user_id, title, content, status, priority,
start_date, due_date, is_all_day, completed_at, repeat_flag, sort_order, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(task_id, t["_our_project_id"], GATEWAY_USER_ID, title,
t.get("content", ""),
status,
t.get("priority", 0),
t.get("startDate"),
t.get("dueDate"),
1 if t.get("isAllDay", True) else 0,
completed_at,
t.get("repeatFlag"),
t.get("sortOrder", 0),
t.get("createdTime") or t.get("modifiedTime")))
migrated += 1
conn.commit()
conn.close()
print(f"\nMigration complete!")
print(f" Migrated: {migrated} tasks")
print(f" Skipped: {skipped} (duplicates or empty)")
print(f" Projects: {len(project_map) + 1} (including Inbox)")
if __name__ == "__main__":
migrate()

760
services/tasks/server.py Normal file
View File

@@ -0,0 +1,760 @@
"""Second Brain Task Manager — self-contained SQLite-backed task service."""
import json
import os
import time
import uuid
import urllib.parse
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from datetime import datetime, timedelta
from pathlib import Path
from threading import Lock
PORT = int(os.environ.get("PORT", 8098))
DATA_DIR = Path(os.environ.get("DATA_DIR", "/app/data"))
DB_PATH = DATA_DIR / "tasks.db"
# ── Database ──
import sqlite3
def get_db():
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("PRAGMA journal_mode = WAL")
return conn
def init_db():
DATA_DIR.mkdir(parents=True, exist_ok=True)
conn = get_db()
c = conn.cursor()
c.execute('''CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)''')
c.execute('''CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
color TEXT DEFAULT '',
sort_order INTEGER DEFAULT 0,
is_inbox INTEGER DEFAULT 0,
is_shared INTEGER DEFAULT 0,
archived INTEGER DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
)''')
c.execute('''CREATE TABLE IF NOT EXISTS project_members (
project_id TEXT NOT NULL,
user_id TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
added_at TEXT DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (project_id, user_id),
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id)
)''')
c.execute('''CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
user_id TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT DEFAULT '',
status INTEGER DEFAULT 0,
priority INTEGER DEFAULT 0,
start_date TEXT,
due_date TEXT,
is_all_day INTEGER DEFAULT 1,
completed_at TEXT,
repeat_flag TEXT,
repeat_from TEXT DEFAULT 'due',
parent_task_id TEXT,
reminders TEXT DEFAULT '[]',
sort_order INTEGER DEFAULT 0,
gcal_event_id TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES projects(id),
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (parent_task_id) REFERENCES tasks(id) ON DELETE SET NULL
)''')
c.execute('''CREATE TABLE IF NOT EXISTS tags (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
color TEXT DEFAULT '',
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, name),
FOREIGN KEY (user_id) REFERENCES users(id)
)''')
c.execute('''CREATE TABLE IF NOT EXISTS task_tags (
task_id TEXT NOT NULL,
tag_id TEXT NOT NULL,
PRIMARY KEY (task_id, tag_id),
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
)''')
c.execute('''CREATE TABLE IF NOT EXISTS completions (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
user_id TEXT NOT NULL,
completed_at TEXT NOT NULL,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id)
)''')
c.execute("CREATE INDEX IF NOT EXISTS idx_tasks_user_status ON tasks(user_id, status)")
c.execute("CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id)")
c.execute("CREATE INDEX IF NOT EXISTS idx_tasks_due ON tasks(due_date)")
c.execute("CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id)")
c.execute("CREATE INDEX IF NOT EXISTS idx_project_members ON project_members(user_id)")
conn.commit()
conn.close()
# ── User + Inbox helpers ──
def ensure_user(user_id, username="", display_name=""):
"""Upsert a user record and ensure they have an Inbox project."""
conn = get_db()
c = conn.cursor()
existing = c.execute("SELECT id FROM users WHERE id = ?", (user_id,)).fetchone()
if not existing:
c.execute("INSERT INTO users (id, username, display_name) VALUES (?, ?, ?)",
(user_id, username, display_name))
# Create Inbox for new user
inbox_id = str(uuid.uuid4())
c.execute("INSERT INTO projects (id, user_id, name, is_inbox, sort_order) VALUES (?, ?, 'Inbox', 1, -1)",
(inbox_id, user_id))
conn.commit()
conn.close()
def get_inbox_id(user_id):
conn = get_db()
row = conn.execute("SELECT id FROM projects WHERE user_id = ? AND is_inbox = 1", (user_id,)).fetchone()
conn.close()
return row["id"] if row else None
def get_user_project_ids(user_id):
"""Get all project IDs the user can access (owned + shared membership)."""
conn = get_db()
owned = [r["id"] for r in conn.execute(
"SELECT id FROM projects WHERE user_id = ? AND archived = 0", (user_id,)).fetchall()]
shared = [r["project_id"] for r in conn.execute(
"SELECT project_id FROM project_members WHERE user_id = ?", (user_id,)).fetchall()]
# Also include projects marked is_shared (visible to all)
global_shared = [r["id"] for r in conn.execute(
"SELECT id FROM projects WHERE is_shared = 1 AND archived = 0 AND user_id != ?", (user_id,)).fetchall()]
conn.close()
return list(set(owned + shared + global_shared))
# ── RRULE Parser ──
def advance_rrule(due_date_str, repeat_flag, from_date_str=None):
"""Given a due date and RRULE string, compute the next occurrence.
Returns ISO date string or None if recurrence is exhausted."""
if not repeat_flag:
return None
base = datetime.fromisoformat(due_date_str.replace("+0000", "+00:00").replace("Z", "+00:00"))
if from_date_str:
base = datetime.fromisoformat(from_date_str.replace("+0000", "+00:00").replace("Z", "+00:00"))
# Parse RRULE components
parts = {}
for segment in repeat_flag.replace("RRULE:", "").split(";"):
if "=" in segment:
k, v = segment.split("=", 1)
parts[k.upper()] = v
freq = parts.get("FREQ", "DAILY").upper()
interval = int(parts.get("INTERVAL", "1"))
until = parts.get("UNTIL")
count = parts.get("COUNT") # not enforced here, checked by caller
# Compute next date
if freq == "DAILY":
next_dt = base + timedelta(days=interval)
elif freq == "WEEKLY":
byday = parts.get("BYDAY", "")
if byday:
day_map = {"MO": 0, "TU": 1, "WE": 2, "TH": 3, "FR": 4, "SA": 5, "SU": 6}
target_days = sorted([day_map[d.strip()] for d in byday.split(",") if d.strip() in day_map])
if target_days:
current_wd = base.weekday()
# Find next target day
found = False
for td in target_days:
if td > current_wd:
next_dt = base + timedelta(days=(td - current_wd))
found = True
break
if not found:
# Wrap to first day of next week(s)
days_to_next = (7 * interval) - current_wd + target_days[0]
next_dt = base + timedelta(days=days_to_next)
else:
next_dt = base + timedelta(weeks=interval)
else:
next_dt = base + timedelta(weeks=interval)
elif freq == "MONTHLY":
bymonthday = parts.get("BYMONTHDAY")
month = base.month + interval
year = base.year + (month - 1) // 12
month = ((month - 1) % 12) + 1
day = int(bymonthday) if bymonthday else base.day
# Clamp day to valid range
import calendar
max_day = calendar.monthrange(year, month)[1]
day = min(day, max_day)
next_dt = base.replace(year=year, month=month, day=day)
elif freq == "YEARLY":
next_dt = base.replace(year=base.year + interval)
else:
return None
# Check UNTIL
if until:
try:
until_dt = datetime.strptime(until[:8], "%Y%m%d").replace(tzinfo=base.tzinfo)
if next_dt > until_dt:
return None
except ValueError:
pass
return next_dt.isoformat()
# ── Task helpers ──
def task_to_dict(row, project_name=""):
"""Convert a SQLite row to the API response format (TickTick-compatible field names)."""
return {
"id": row["id"],
"title": row["title"],
"content": row["content"] or "",
"projectId": row["project_id"],
"_projectName": project_name or "",
"_projectId": row["project_id"],
"dueDate": row["due_date"],
"startDate": row["start_date"],
"isAllDay": bool(row["is_all_day"]),
"priority": row["priority"],
"status": row["status"],
"repeatFlag": row["repeat_flag"] or "",
"completedAt": row["completed_at"],
"reminders": json.loads(row["reminders"] or "[]"),
"createdAt": row["created_at"],
"sortOrder": row["sort_order"],
}
def fetch_tasks_with_projects(conn, where_clause, params, user_id):
"""Fetch tasks joined with project names, scoped to user's accessible projects."""
project_ids = get_user_project_ids(user_id)
if not project_ids:
return []
placeholders = ",".join("?" * len(project_ids))
sql = f"""SELECT t.*, p.name as project_name FROM tasks t
JOIN projects p ON t.project_id = p.id
WHERE t.project_id IN ({placeholders}) AND {where_clause}
ORDER BY t.start_date IS NULL, t.start_date, t.due_date, t.sort_order"""
rows = conn.execute(sql, project_ids + list(params)).fetchall()
return [task_to_dict(r, r["project_name"]) for r in rows]
# ── HTTP Handler ──
class Handler(BaseHTTPRequestHandler):
def _read_body(self):
length = int(self.headers.get("Content-Length", 0))
return json.loads(self.rfile.read(length)) if length else {}
def _send_json(self, data, status=200):
body = json.dumps(data).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _send_error(self, msg, status=500):
self._send_json({"error": msg}, status)
def _parse_query(self):
qs = self.path.split("?", 1)[1] if "?" in self.path else ""
return dict(urllib.parse.parse_qsl(qs))
def _get_user(self):
"""Get user identity from gateway-injected headers."""
user_id = self.headers.get("X-Gateway-User-Id")
if not user_id:
return None
username = self.headers.get("X-Gateway-User-Name", "")
ensure_user(user_id, username, username)
return user_id
# ── GET ──
def do_GET(self):
path = self.path.split("?")[0]
if path == "/health":
self._send_json({"status": "ok"})
return
user_id = self._get_user()
if not user_id:
self._send_error("Unauthorized", 401)
return
# List projects
if path == "/api/projects":
try:
conn = get_db()
project_ids = get_user_project_ids(user_id)
if not project_ids:
self._send_json({"projects": []})
conn.close()
return
placeholders = ",".join("?" * len(project_ids))
rows = conn.execute(
f"SELECT * FROM projects WHERE id IN ({placeholders}) ORDER BY is_inbox DESC, sort_order, name",
project_ids).fetchall()
conn.close()
projects = [{"id": r["id"], "name": r["name"], "color": r["color"],
"isInbox": bool(r["is_inbox"]), "isShared": bool(r["is_shared"]),
"sortOrder": r["sort_order"]} for r in rows]
self._send_json({"projects": projects})
except Exception as e:
self._send_error(str(e))
return
# List all tasks (optional project filter)
if path == "/api/tasks":
try:
params = self._parse_query()
project_id = params.get("project_id")
conn = get_db()
if project_id:
tasks = fetch_tasks_with_projects(conn, "t.status = 0 AND t.project_id = ?", (project_id,), user_id)
else:
tasks = fetch_tasks_with_projects(conn, "t.status = 0", (), user_id)
conn.close()
self._send_json({"tasks": tasks})
except Exception as e:
self._send_error(str(e))
return
# Today + overdue (dashboard widget)
if path == "/api/today":
try:
conn = get_db()
now = datetime.now()
today_str = now.strftime("%Y-%m-%d")
all_active = fetch_tasks_with_projects(conn, "t.status = 0", (), user_id)
conn.close()
today_tasks = []
overdue_tasks = []
for t in all_active:
due = t.get("startDate") or t.get("dueDate")
if not due:
continue
due_date = due[:10]
if due_date == today_str:
today_tasks.append(t)
elif due_date < today_str:
overdue_tasks.append(t)
self._send_json({
"today": today_tasks,
"overdue": overdue_tasks,
"todayCount": len(today_tasks),
"overdueCount": len(overdue_tasks),
})
except Exception as e:
self._send_error(str(e))
return
# Completed tasks
if path == "/api/tasks/completed":
try:
conn = get_db()
tasks = fetch_tasks_with_projects(conn, "t.status = 2", (), user_id)
conn.close()
# Sort by completed_at descending
tasks.sort(key=lambda t: t.get("completedAt") or "", reverse=True)
self._send_json({"tasks": tasks[:50]})
except Exception as e:
self._send_error(str(e))
return
# List tags
if path == "/api/tags":
try:
conn = get_db()
rows = conn.execute("SELECT * FROM tags WHERE user_id = ? ORDER BY name", (user_id,)).fetchall()
conn.close()
self._send_json({"tags": [{"id": r["id"], "name": r["name"], "color": r["color"]} for r in rows]})
except Exception as e:
self._send_error(str(e))
return
# Get single project with tasks
if path.startswith("/api/projects/") and path.count("/") == 3:
project_id = path.split("/")[3]
try:
conn = get_db()
proj = conn.execute("SELECT * FROM projects WHERE id = ?", (project_id,)).fetchone()
if not proj:
self._send_error("Not found", 404)
conn.close()
return
tasks = fetch_tasks_with_projects(conn, "t.status = 0 AND t.project_id = ?", (project_id,), user_id)
conn.close()
self._send_json({"project": {"id": proj["id"], "name": proj["name"]}, "tasks": tasks})
except Exception as e:
self._send_error(str(e))
return
self._send_json({"error": "Not found"}, 404)
# ── POST ──
def do_POST(self):
path = self.path.split("?")[0]
body = self._read_body()
user_id = self._get_user()
if not user_id:
self._send_error("Unauthorized", 401)
return
# Create task
if path == "/api/tasks":
try:
title = body.get("title", "").strip()
if not title:
self._send_error("Title required", 400)
return
project_id = body.get("projectId") or get_inbox_id(user_id)
task_id = str(uuid.uuid4())
conn = get_db()
conn.execute("""INSERT INTO tasks (id, project_id, user_id, title, content, priority,
start_date, due_date, is_all_day, repeat_flag, reminders, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(task_id, project_id, user_id, title,
body.get("content", ""),
body.get("priority", 0),
body.get("startDate"),
body.get("dueDate"),
1 if body.get("isAllDay", True) else 0,
body.get("repeatFlag"),
json.dumps(body.get("reminders", [])),
body.get("sortOrder", 0)))
# Handle tags
for tag_name in body.get("tags", []):
tag_id = str(uuid.uuid4())
conn.execute("INSERT OR IGNORE INTO tags (id, user_id, name) VALUES (?, ?, ?)",
(tag_id, user_id, tag_name))
tag_row = conn.execute("SELECT id FROM tags WHERE user_id = ? AND name = ?",
(user_id, tag_name)).fetchone()
if tag_row:
conn.execute("INSERT OR IGNORE INTO task_tags (task_id, tag_id) VALUES (?, ?)",
(task_id, tag_row["id"]))
conn.commit()
# Return created task
row = conn.execute("SELECT t.*, p.name as project_name FROM tasks t JOIN projects p ON t.project_id = p.id WHERE t.id = ?",
(task_id,)).fetchone()
conn.close()
self._send_json(task_to_dict(row, row["project_name"]), 201)
except Exception as e:
self._send_error(str(e))
return
# Complete task
if path.startswith("/api/tasks/") and path.endswith("/complete"):
task_id = path.split("/")[3]
try:
conn = get_db()
task = conn.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone()
if not task:
self._send_error("Not found", 404)
conn.close()
return
now_str = datetime.now().isoformat()
# Mark complete
conn.execute("UPDATE tasks SET status = 2, completed_at = ?, updated_at = ? WHERE id = ?",
(now_str, now_str, task_id))
# Record completion
conn.execute("INSERT INTO completions (id, task_id, user_id, completed_at) VALUES (?, ?, ?, ?)",
(str(uuid.uuid4()), task_id, user_id, now_str))
# Handle recurrence — spawn next instance
if task["repeat_flag"]:
base_date = task["due_date"] or task["start_date"]
if task["repeat_from"] == "completion":
base_date = now_str
next_date = advance_rrule(base_date, task["repeat_flag"])
if next_date:
new_id = str(uuid.uuid4())
# Calculate start_date offset if both existed
new_start = None
if task["start_date"] and task["due_date"]:
try:
orig_start = datetime.fromisoformat(task["start_date"].replace("+0000", "+00:00"))
orig_due = datetime.fromisoformat(task["due_date"].replace("+0000", "+00:00"))
new_due = datetime.fromisoformat(next_date.replace("+0000", "+00:00"))
offset = orig_due - orig_start
new_start = (new_due - offset).isoformat()
except:
new_start = next_date
elif task["start_date"]:
new_start = next_date
conn.execute("""INSERT INTO tasks (id, project_id, user_id, title, content, priority,
start_date, due_date, is_all_day, repeat_flag, repeat_from,
parent_task_id, reminders, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(new_id, task["project_id"], task["user_id"], task["title"],
task["content"], task["priority"],
new_start, next_date, task["is_all_day"],
task["repeat_flag"], task["repeat_from"],
task["parent_task_id"] or task["id"],
task["reminders"], task["sort_order"]))
conn.commit()
conn.close()
self._send_json({"status": "completed"})
except Exception as e:
self._send_error(str(e))
return
# Create project
if path == "/api/projects":
try:
name = body.get("name", "").strip()
if not name:
self._send_error("Name required", 400)
return
project_id = str(uuid.uuid4())
conn = get_db()
conn.execute("INSERT INTO projects (id, user_id, name, color, is_shared) VALUES (?, ?, ?, ?, ?)",
(project_id, user_id, name, body.get("color", ""), 1 if body.get("isShared") else 0))
conn.commit()
conn.close()
self._send_json({"id": project_id, "name": name}, 201)
except Exception as e:
self._send_error(str(e))
return
# Create tag
if path == "/api/tags":
try:
name = body.get("name", "").strip()
if not name:
self._send_error("Name required", 400)
return
tag_id = str(uuid.uuid4())
conn = get_db()
conn.execute("INSERT INTO tags (id, user_id, name, color) VALUES (?, ?, ?, ?)",
(tag_id, user_id, name, body.get("color", "")))
conn.commit()
conn.close()
self._send_json({"id": tag_id, "name": name}, 201)
except Exception as e:
self._send_error(str(e))
return
# Share project
if path.startswith("/api/projects/") and path.endswith("/share"):
project_id = path.split("/")[3]
try:
target_user_id = body.get("userId")
if not target_user_id:
self._send_error("userId required", 400)
return
conn = get_db()
conn.execute("INSERT OR IGNORE INTO project_members (project_id, user_id) VALUES (?, ?)",
(project_id, target_user_id))
conn.commit()
conn.close()
self._send_json({"status": "shared"})
except Exception as e:
self._send_error(str(e))
return
self._send_json({"error": "Not found"}, 404)
# ── PATCH ──
def do_PATCH(self):
path = self.path.split("?")[0]
body = self._read_body()
user_id = self._get_user()
if not user_id:
self._send_error("Unauthorized", 401)
return
# Update task
if path.startswith("/api/tasks/") and path.count("/") == 3:
task_id = path.split("/")[3]
try:
conn = get_db()
task = conn.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone()
if not task:
self._send_error("Not found", 404)
conn.close()
return
# Build SET clause from provided fields
updates = []
params = []
field_map = {
"title": "title", "content": "content", "priority": "priority",
"startDate": "start_date", "dueDate": "due_date",
"isAllDay": "is_all_day", "repeatFlag": "repeat_flag",
"projectId": "project_id", "sortOrder": "sort_order",
"reminders": "reminders",
}
for api_field, db_field in field_map.items():
if api_field in body:
val = body[api_field]
if api_field == "isAllDay":
val = 1 if val else 0
elif api_field == "reminders":
val = json.dumps(val)
updates.append(f"{db_field} = ?")
params.append(val)
if updates:
updates.append("updated_at = ?")
params.append(datetime.now().isoformat())
params.append(task_id)
conn.execute(f"UPDATE tasks SET {', '.join(updates)} WHERE id = ?", params)
conn.commit()
row = conn.execute("SELECT t.*, p.name as project_name FROM tasks t JOIN projects p ON t.project_id = p.id WHERE t.id = ?",
(task_id,)).fetchone()
conn.close()
self._send_json(task_to_dict(row, row["project_name"]))
except Exception as e:
self._send_error(str(e))
return
# Update project
if path.startswith("/api/projects/") and path.count("/") == 3:
project_id = path.split("/")[3]
try:
conn = get_db()
updates = []
params = []
if "name" in body:
updates.append("name = ?")
params.append(body["name"])
if "color" in body:
updates.append("color = ?")
params.append(body["color"])
if updates:
updates.append("updated_at = ?")
params.append(datetime.now().isoformat())
params.append(project_id)
conn.execute(f"UPDATE projects SET {', '.join(updates)} WHERE id = ?", params)
conn.commit()
conn.close()
self._send_json({"status": "updated"})
except Exception as e:
self._send_error(str(e))
return
self._send_json({"error": "Not found"}, 404)
# ── DELETE ──
def do_DELETE(self):
path = self.path.split("?")[0]
user_id = self._get_user()
if not user_id:
self._send_error("Unauthorized", 401)
return
# Delete task
if path.startswith("/api/tasks/") and path.count("/") == 3:
task_id = path.split("/")[3]
try:
conn = get_db()
conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,))
conn.commit()
conn.close()
self._send_json({"status": "deleted"})
except Exception as e:
self._send_error(str(e))
return
# Delete/archive project
if path.startswith("/api/projects/") and path.count("/") == 3:
project_id = path.split("/")[3]
try:
conn = get_db()
proj = conn.execute("SELECT is_inbox FROM projects WHERE id = ?", (project_id,)).fetchone()
if proj and proj["is_inbox"]:
self._send_error("Cannot delete Inbox", 400)
conn.close()
return
conn.execute("UPDATE projects SET archived = 1, updated_at = ? WHERE id = ?",
(datetime.now().isoformat(), project_id))
conn.commit()
conn.close()
self._send_json({"status": "archived"})
except Exception as e:
self._send_error(str(e))
return
# Delete tag
if path.startswith("/api/tags/") and path.count("/") == 3:
tag_id = path.split("/")[3]
try:
conn = get_db()
conn.execute("DELETE FROM tags WHERE id = ? AND user_id = ?", (tag_id, user_id))
conn.commit()
conn.close()
self._send_json({"status": "deleted"})
except Exception as e:
self._send_error(str(e))
return
self._send_json({"error": "Not found"}, 404)
def log_message(self, format, *args):
pass
# ── Start ──
if __name__ == "__main__":
init_db()
print(f"Task Manager listening on port {PORT}")
server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
server.serve_forever()

View File

@@ -1958,6 +1958,11 @@ class TripHandler(BaseHTTPRequestHandler):
self.handle_oidc_callback()
return
# Health check (before auth)
if path == "/api/health":
self.send_json({"status": "ok"})
return
# Protected routes
if not self.is_authenticated():
# Return JSON 401 for API requests, redirect for browser