feat: full plan execution - CI/CD, critical fixes, UX polish, secondary/advanced features, E2E + docs

Phase 0 (CI/CD): fix root typecheck to cover api+worker+web; reconcile migration
story into idempotent db:migrate (db:sync + db:triggers); add Gitea Actions
quality/deploy/smoke workflow; rewrite README/AGENTS/DEPLOY docs; add
requireWorkspaceAccess + recordActivityForEntity conventions.

Phase 1 (critical fixes): calendar delete + drag/resize DnD; canvas card CRUD +
bulk save + debounced autosave; logout route; graph edge workspaceId derivation;
real analytics endpoints (drop Math.random); task board droppable columns +
reorder persistence; Tiptap notes editor with sanitized HTML rendering; remove
insecure passkey auth; domain/owner scoping (IDOR) on all by-ID routes + search/
export/realtime scoping; command palette routing + agent mention fetch; agent
activity SSE handler; graph fly-to with tracked positions.

Phase 2 (UX polish): login on design system; Sonner toasts app-wide; shared
Loading/Empty/Error state components; working density/sidebarPos/reduce-motion
settings; Inter typography; consolidated status-colors lib; unified detail
routes; dashboard sort/realtime/responsive fixes; mobile responsive; a11y
(radiogroups, sanitized snippets, badge labels).

Phase 3 (features): daily notes timezone fix + delete + autosave + mood/energy
create; active-domain store + topbar picker; graph domain picker + navigable
entity links; tag assign/remove UI + server-side tag filter; real CSV export +
import validation; custom fields on tasks.

Phase 4 (advanced): migrate job worker into apps/worker (webhook delivery with
HMAC, recurring spawn, ai_dispatch disabled); webhook queue helper + entity
event enqueuing + test endpoint fix; recurring scheduledJobs pipeline; agents
CRUD + permission editing + activity filters; real notifications feed; MCP
polish (validation, error codes, domain scoping, dead sql leftover).

Phase 5 (E2E + docs): rewrite Playwright suite for the Vite SPA (15 specs, new
auth helpers, chromium-only in CI); add ephemeral-Postgres e2e CI job; rewrite
docs/API.md for the real Hono API.
This commit is contained in:
2026-08-10 08:53:18 +00:00
parent 6cb4b9f1b5
commit a60b75f075
99 changed files with 6238 additions and 2954 deletions
+220
View File
@@ -0,0 +1,220 @@
name: ci-cd
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
jobs:
quality:
runs-on: projecte-runner
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
# Self-hosted Gitea runners may not be able to resolve actions from
# github.com. If the checkout action above failed (e.g. it could not be
# fetched), fall back to a manual shallow clone from the Gitea server.
- name: Fallback checkout (manual clone)
if: failure()
shell: bash
run: |
set -euo pipefail
SERVER_URL="${{ github.server_url }}"
REPO="${{ github.repository }}"
TOKEN="${{ github.token }}"
HOST="${SERVER_URL#https://}"
HOST="${HOST#http://}"
rm -rf ./* ./.git 2>/dev/null || true
echo "Manual shallow clone from ${HOST}/${REPO}"
git clone --depth 1 "https://oauth2:${TOKEN}@${HOST}/${REPO}.git" .
- name: Ensure bun
shell: bash
run: command -v bun || npm i -g bun@1.3.14
- name: Install dependencies
shell: bash
run: bun install --frozen-lockfile || bun install
- name: Typecheck
shell: bash
run: bun run typecheck
- name: Build web
shell: bash
run: cd apps/web && bun run build
- name: Docker compose build
shell: bash
run: docker compose build
e2e:
runs-on: projecte-runner
needs: quality
timeout-minutes: 25
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
# Self-hosted Gitea runners may not be able to resolve actions from
# github.com. If the checkout action above failed (e.g. it could not be
# fetched), fall back to a manual shallow clone from the Gitea server.
- name: Fallback checkout (manual clone)
if: failure()
shell: bash
run: |
set -euo pipefail
SERVER_URL="${{ github.server_url }}"
REPO="${{ github.repository }}"
TOKEN="${{ github.token }}"
HOST="${SERVER_URL#https://}"
HOST="${HOST#http://}"
rm -rf ./* ./.git 2>/dev/null || true
echo "Manual shallow clone from ${HOST}/${REPO}"
git clone --depth 1 "https://oauth2:${TOKEN}@${HOST}/${REPO}.git" .
- name: Ensure bun
shell: bash
run: command -v bun || npm i -g bun@1.3.14
- name: Install dependencies
shell: bash
run: bun install --frozen-lockfile || bun install
- name: Start ephemeral Postgres
shell: bash
run: |
set -euo pipefail
docker rm -f projecte-e2e-db >/dev/null 2>&1 || true
docker run -d --name projecte-e2e-db \
-e POSTGRES_PASSWORD=test \
-e POSTGRES_DB=project_e \
-e POSTGRES_USER=project_e \
-p 5433:5432 \
postgres:16-alpine
echo "Waiting for Postgres to accept connections..."
for i in $(seq 1 30); do
if docker exec projecte-e2e-db pg_isready -U project_e -d project_e >/dev/null 2>&1; then
echo "Postgres ready"
exit 0
fi
sleep 1
done
echo "ERROR: Postgres did not become ready in time"
exit 1
- name: Sync database schema
shell: bash
env:
DATABASE_URL: postgresql://project_e:test@localhost:5433/project_e
run: bun run db:migrate
- name: Install Playwright chromium
shell: bash
run: npx playwright install --with-deps chromium
# The Playwright webServer boots the API + Vite dev server itself via
# `bun run dev`. The API needs the ephemeral DB connection, and the admin
# auto-creation on first login depends on INITIAL_ADMIN_* — the same
# values the e2e fixtures fall back to.
- name: Run E2E tests (chromium)
shell: bash
env:
DATABASE_URL: postgresql://project_e:test@localhost:5433/project_e
INITIAL_ADMIN_EMAIL: admin@example.com
INITIAL_ADMIN_PASSWORD: testpassword123
CI: "1"
run: bunx playwright test
- name: Stop ephemeral Postgres
if: always()
shell: bash
run: docker rm -f projecte-e2e-db >/dev/null 2>&1 || true
deploy:
runs-on: projecte-runner
needs: quality
if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch'
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Fallback checkout (manual clone)
if: failure()
shell: bash
run: |
set -euo pipefail
SERVER_URL="${{ github.server_url }}"
REPO="${{ github.repository }}"
TOKEN="${{ github.token }}"
HOST="${SERVER_URL#https://}"
HOST="${HOST#http://}"
rm -rf ./* ./.git 2>/dev/null || true
echo "Manual shallow clone from ${HOST}/${REPO}"
git clone --depth 1 "https://oauth2:${TOKEN}@${HOST}/${REPO}.git" .
- name: Deploy
shell: bash
env:
DEPLOY_DIR: ${{ vars.DEPLOY_DIR || '/home/projecte/ProjectE' }}
run: bash script/deploy.sh
smoke:
runs-on: projecte-runner
needs: deploy
if: always()
timeout-minutes: 10
steps:
- name: API health check
shell: bash
run: |
set -euo pipefail
BODY="$(curl -s http://localhost:3000/api/health || true)"
echo "${BODY}"
echo "${BODY}" | grep -q '"status"' || {
echo "ERROR: API health did not return the expected payload"
exit 1
}
echo "API health: OK"
- name: SPA root returns HTML
shell: bash
run: |
set -euo pipefail
BODY="$(curl -s http://localhost:3000/ || true)"
echo "${BODY}" | grep -qi '<html' || {
echo "ERROR: SPA root did not return HTML"
exit 1
}
echo "SPA root: HTML OK"
- name: Login smoke test
shell: bash
run: |
set -euo pipefail
set -a
. /home/projecte/ProjectE/.env
set +a
CODE="$(curl -s -o /tmp/smoke-login.json -w '%{http_code}' \
-X POST http://localhost:3000/api/auth/credentials \
-H 'Content-Type: application/json' \
-d "{\"email\":\"${INITIAL_ADMIN_EMAIL}\",\"password\":\"${INITIAL_ADMIN_PASSWORD}\"}" \
|| true)"
echo "Login HTTP status: ${CODE}"
cat /tmp/smoke-login.json
if [ "${CODE}" != "200" ]; then
echo "ERROR: Login smoke test failed (expected HTTP 200)"
exit 1
fi
echo "Login smoke test: OK"
+30 -1
View File
@@ -1,5 +1,14 @@
# AGENTS.md — Project E v2 Agent Contract # AGENTS.md — Project E v2 Agent Contract
## Stack
- Monorepo: Turborepo + Bun workspaces, `packageManager bun@1.3.14`. Use `bun` for every command. Do not use `npm`.
- `apps/web`: Vite + React 19 SPA (TanStack Router/Query, shadcn/ui, Tailwind). Dev server on :3000 proxies `/api` and `/mcp` to :3001.
- `apps/api`: Hono API server on Bun, port 3001 (Docker) / 3000 via the Vite proxy in dev.
- `apps/worker`: Bun background worker (`src/index.ts`). The root `worker/` directory is legacy and NOT used.
- `apps/web-legacy`: legacy v1 app, kept for reference only. Do not edit.
- `packages/db`: Drizzle ORM + `postgres` client. `packages/shared`: shared types, schemas, constants.
## Core Rules ## Core Rules
Every API route that writes data (INSERT/UPDATE/DELETE) MUST follow this pattern: Every API route that writes data (INSERT/UPDATE/DELETE) MUST follow this pattern:
@@ -8,6 +17,13 @@ Every API route that writes data (INSERT/UPDATE/DELETE) MUST follow this pattern
2. **Activity feed insert** — Call `recordActivity()` with actor, action, entity_type, entity_id, changes, workspace_id 2. **Activity feed insert** — Call `recordActivity()` with actor, action, entity_type, entity_id, changes, workspace_id
3. **pg_notify** — `recordActivity()` handles this automatically via `pg.notify('project_e_events', payload)` 3. **pg_notify** — `recordActivity()` handles this automatically via `pg.notify('project_e_events', payload)`
## Shared Helpers
Both live in `apps/api/src/middleware/`. Use them; do not re-implement.
- `requireWorkspaceAccess(c, workspaceId)` (`middleware/auth.ts`) — verifies a workspace exists and the current user owns it. Returns the domain row. Throws 403 FORBIDDEN when the id is missing/empty or not owned, 404 NOT_FOUND when no such workspace exists. Call it at the top of every workspace-scoped route.
- `recordActivityForEntity({ actor, action, entityType, entityId, changes, workspaceId })` (`middleware/activity.ts`) — same as `recordActivity()` but resolves the workspace from the entity row when `workspaceId` is omitted. Unknown entity types or unresolvable entities are logged and skipped, never fatal to the request.
## Soft-Delete Only ## Soft-Delete Only
- Never use SQL `DELETE` on user data tables - Never use SQL `DELETE` on user data tables
@@ -38,9 +54,17 @@ Standard codes: `VALIDATION_ERROR`, `NOT_FOUND`, `UNAUTHORIZED`, `FORBIDDEN`, `C
- Every entity table has a `domain_id` FK (or `workspace_id` for activity_feed/webhooks) - Every entity table has a `domain_id` FK (or `workspace_id` for activity_feed/webhooks)
- Use `requireWorkspaceAccess(workspaceId)` to verify the workspace exists - Use `requireWorkspaceAccess(workspaceId)` to verify the workspace exists
## CI/CD Contract
- CI runs on Gitea Actions (`.gitea/workflows/ci.yml`) on the self-hosted runner `projecte-runner`.
- Every push and pull request must pass the `quality` job: `bun install --frozen-lockfile` → `bun run typecheck` → web build (`cd apps/web && bun run build`) → `docker compose build`.
- A push to `main` (or a manual `workflow_dispatch`) triggers `deploy`, which runs `bash script/deploy.sh`. The `smoke` job then checks API health, SPA HTML, and login.
- Do not commit build artifacts (`.next/`, `dist/`, `.turbo/`, `*.tsbuildinfo`).
## Build Before Commit ## Build Before Commit
- Run `npm run build --workspace=apps/web` before committing - Run `bun run typecheck` (typechecks api, worker, and web)
- Run `cd apps/web && bun run build` to verify the SPA builds
- Do NOT commit build artifacts (`.next/`, `dist/`, `.turbo/`) - Do NOT commit build artifacts (`.next/`, `dist/`, `.turbo/`)
## Schema ## Schema
@@ -50,6 +74,11 @@ Standard codes: `VALIDATION_ERROR`, `NOT_FOUND`, `UNAUTHORIZED`, `FORBIDDEN`, `C
- Use Drizzle ORM for all database operations - Use Drizzle ORM for all database operations
- Never write raw SQL except for `pg_notify` calls - Never write raw SQL except for `pg_notify` calls
## Migrations
- Migrations live in `drizzle/` (0000–0005). Generate a new one with `bun run db:generate` after editing the schema.
- `bun run db:migrate` is the deploy-time migration: `db:sync` (`drizzle-kit push --force`) then `db:triggers` (`script/apply-triggers.ts`, search-vector triggers). Both are idempotent; safe to run on every deploy.
## Realtime ## Realtime
- SSE endpoint at `/api/realtime` uses PostgreSQL LISTEN/NOTIFY - SSE endpoint at `/api/realtime` uses PostgreSQL LISTEN/NOTIFY
+106 -154
View File
@@ -1,39 +1,109 @@
# Project E — Deploy Guide # Project E — Deploy Guide
## How to redeploy Production runs as a docker-compose stack on the deploy host (`10.0.0.204`). The stack has four services: PostgreSQL, the Hono API, the Vite SPA served by Caddy, and the Bun worker. Deploys are normally driven by Gitea Actions, but every step below can be run by hand.
## Architecture
```
Internet → :3000 (SPA container, Caddy)
├── /api/* → api:3000 (Hono/Bun)
├── /mcp* → api:3000 (Hono/Bun)
└── /* → index.html (SPA fallback)
API (:3001, direct) → PostgreSQL (:5432)
Worker → PostgreSQL
```
Caddy (in the `spa` container) serves the built SPA and reverse-proxies `/api/*` and `/mcp*` to the `api` service. The API is also exposed directly on :3001 for debugging.
## Services
| Service | Container | Image / Build | Ports | Notes |
|---------|-----------|---------------|-------|-------|
| `db` | `project-e-db` | `postgres:16-alpine` | 5432 | Data in the `project-e-pg-data` volume |
| `api` | `project-e-api` | `Dockerfile.api` | 3001 → 3000 | Hono on Bun |
| `spa` | `project-e-spa` | `Dockerfile.spa` | 3000 → 80 | Built Vite SPA + Caddy |
| `worker` | `project-e-worker` | `Dockerfile.worker` | none | Bun worker |
All services share the `project-e-network` bridge and restart unless stopped.
## CI/CD pipeline (Gitea Actions)
The workflow lives at `.gitea/workflows/ci.yml` and runs on the self-hosted runner `projecte-runner`, which is colocated with the deploy host.
- **`quality`** — runs on every push and pull request: `bun install --frozen-lockfile` → `bun run typecheck` → web build (`cd apps/web && bun run build`) → `docker compose build`. A failed quality gate blocks the deploy job.
- **`deploy`** — runs on pushes to `main` and on `workflow_dispatch`. It checks out the code and runs `bash script/deploy.sh` with `DEPLOY_DIR` defaulting to `/home/projecte/ProjectE`.
- **`smoke`** — runs after `deploy` (also on deploy failure): API health at `http://localhost:3000/api/health`, SPA root returns HTML, and a login POST to `/api/auth/credentials` using `INITIAL_ADMIN_EMAIL`/`INITIAL_ADMIN_PASSWORD` from the host `.env`.
### What `script/deploy.sh` does
Idempotent, safe to re-run. It:
1. Syncs the CI checkout into `DEPLOY_DIR` (rsync, excluding `.git`, `node_modules`, build artifacts, and `.env`)
2. Loads secrets from the host `.env` (never overwrites it)
3. Installs dependencies with `bun install --frozen-lockfile`
4. Runs `bun run db:migrate`
5. Runs `docker compose build` and `docker compose up -d`
6. Waits up to 60s for `http://localhost:3000/api/health` to return HTTP 200, dumping recent API logs if it times out
## Secrets
Secrets live in the host `.env` at `/home/projecte/ProjectE/.env`. This file is gitignored; never commit it. To set it up:
```bash ```bash
cd ~/ProjectE cp .env.example .env
```
Fill in `POSTGRES_PASSWORD`, `DATABASE_URL`, `AUTH_SECRET` (or `NEXTAUTH_SECRET`), `INITIAL_ADMIN_EMAIL`, `INITIAL_ADMIN_PASSWORD`, and, as needed, `NODE_ENV`, `PUBLIC_URL`, `COOKIE_SECURE`, and `ALLOWED_HOSTS`. `deploy.sh` sources it, and docker-compose reads the `POSTGRES_PASSWORD` and `DATABASE_URL` values from it.
## Manual deploy
On the deploy host:
```bash
cd /home/projecte/ProjectE
# Pull latest # Pull latest
git pull origin redesign/ui-v2 git pull origin main
# Rebuild images # Install dependencies
bun install
# Apply schema + triggers (idempotent)
bun run db:migrate
# Build images
docker compose build docker compose build
# Restart stack # Restart the stack
docker compose up -d docker compose up -d
# Wait for API health
until curl -s http://localhost:3000/api/health | grep -q '"status"'; do sleep 2; done
# Check status # Check status
docker compose ps docker compose ps
``` ```
## How to roll back ## Database migrations
If the new stack fails: `bun run db:migrate` chains two idempotent steps:
```bash - `db:sync` — `drizzle-kit push --force`, which syncs the schema in `packages/db/src/schema.ts` to the database
cd ~/ProjectE - `db:triggers` — `script/apply-triggers.ts`, which applies the search-vector triggers from `drizzle/0005_search_vector_trigger.sql` (`CREATE OR REPLACE FUNCTION` + `DROP TRIGGER IF EXISTS`)
# Stop the new stack Both are safe to run on every deploy. Schema changes go through `bun run db:generate` in development, then land in `drizzle/` before the next deploy.
docker compose down
# Restart the old worker (Node) from the legacy compose ## Rollback
# (The old compose file is preserved in git history)
# docker compose -f docker-compose.legacy.yml up -d worker
```
## How to view logs Compose images are rebuilt from the checkout, so there are no pinned image tags to restore. To roll back a bad release:
1. Revert the checkout to the previous good commit: `git revert <sha>` (or `git checkout <sha>`) and push to `main`
2. Re-run the manual deploy steps (`docker compose build`, `docker compose up -d`)
The `project-e-pg-data` volume is untouched by deploys and rollbacks, so the database survives both. If a deploy failed, `deploy.sh` exits non-zero with the recent API logs; do not force it past a failing health check.
## Logs
```bash ```bash
# All services # All services
@@ -46,174 +116,56 @@ docker compose logs --tail=50 -f worker
docker compose logs --tail=50 -f db docker compose logs --tail=50 -f db
``` ```
## How to debug ## Debugging
### API health (direct, :3001)
### API health check
```bash ```bash
curl http://localhost:3001/api/health curl http://localhost:3001/api/health
``` ```
### SPA health check Returns `{"status":"ok", ...}` with a database ping (`database.connected`, `database.ping_ms`).
```bash
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/ ### API health (through Caddy)
```
### API through reverse proxy
```bash ```bash
curl http://localhost:3000/api/health curl http://localhost:3000/api/health
``` ```
### Login test
```bash
TOKEN=$(curl -s -X POST http://localhost:3000/api/auth/credentials \
-H "Content-Type: application/json" \
-d email:user@example.com | \
python3 -c "import sys,json; print(json.load(sys.stdin).get(token,))")
echo "Token: $TOKEN"
```
### Check container health
```bash
docker inspect project-e-db --format {{.State.Health.Status}}
```
### Restart a single service
```bash
docker compose restart api
docker compose restart spa
```
### Rebuild a single service
```bash
docker compose build spa
docker compose up -d --force-recreate spa
```
## Architecture
```
Internet → :3000 → Caddy (SPA container)
├── /api/* → api:3000 (Hono/Bun)
├── /mcp* → api:3000 (Hono/Bun)
└── /* → index.html (SPA fallback)
API (:3001, direct) → PostgreSQL (:5432)
Worker → PostgreSQL
```
## Important notes
- The `project-e-pg-data` Docker volume contains the live database. **Do not delete it.**
- The `apps/web-legacy/` directory contains the old Next.js app for reference. **Do not delete it.**
- Port 3000 is the SPA (Caddy), port 3001 is the API directly (for debugging).
- The MCP endpoint requires a valid API key (separate from JWT auth).
EOF cd ~/ProjectE && cat > DEPLOY.md << 'EOF'
# Project E — Deploy Guide
## How to redeploy
```bash
cd ~/ProjectE
# Pull latest
git pull origin redesign/ui-v2
# Rebuild images
docker compose build
# Restart stack
docker compose up -d
# Check status
docker compose ps
```
## How to roll back
If the new stack fails:
```bash
cd ~/ProjectE
# Stop the new stack
docker compose down
# Restart the old worker (Node) from the legacy compose
# (The old compose file is preserved in git history)
# docker compose -f docker-compose.legacy.yml up -d worker
```
## How to view logs
```bash
# All services
docker compose logs --tail=50 -f
# Specific service
docker compose logs --tail=50 -f api
docker compose logs --tail=50 -f spa
docker compose logs --tail=50 -f worker
docker compose logs --tail=50 -f db
```
## How to debug
### API health check
```bash
curl http://localhost:3001/api/health
```
### SPA health check ### SPA health check
```bash ```bash
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/
``` ```
### API through reverse proxy
```bash
curl http://localhost:3000/api/health
```
### Login test ### Login test
```bash ```bash
TOKEN=$(curl -s -X POST http://localhost:3000/api/auth/credentials \ curl -s -X POST http://localhost:3000/api/auth/credentials \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d password:<password> | \ -d '{"email":"<INITIAL_ADMIN_EMAIL>","password":"<INITIAL_ADMIN_PASSWORD>"}'
python3 -c "import sys,json; print(json.load(sys.stdin).get(token,))")
echo "Token: $TOKEN"
``` ```
### Check container health Expect HTTP 200 and a `session` cookie.
### Container health
```bash ```bash
docker inspect project-e-db --format {{.State.Health.Status}} docker inspect project-e-db --format '{{.State.Health.Status}}'
``` ```
### Restart a single service ### Restart or rebuild one service
```bash ```bash
docker compose restart api docker compose restart api
docker compose restart spa
```
### Rebuild a single service
```bash
docker compose build spa docker compose build spa
docker compose up -d --force-recreate spa docker compose up -d --force-recreate spa
``` ```
## Architecture
```
Internet → :3000 → Caddy (SPA container)
├── /api/* → api:3000 (Hono/Bun)
├── /mcp* → api:3000 (Hono/Bun)
└── /* → index.html (SPA fallback)
API (:3001, direct) → PostgreSQL (:5432)
Worker → PostgreSQL
```
## Important notes ## Important notes
- The `project-e-pg-data` Docker volume contains the live database. **Do not delete it.** - The `project-e-pg-data` Docker volume contains the live database. **Do not delete it.** Back it up (volume snapshot or `pg_dump`) before major schema work.
- The `apps/web-legacy/` directory contains the old Next.js app for reference. **Do not delete it.** - Port 3000 is the SPA (Caddy); port 3001 is the API directly (for debugging).
- Port 3000 is the SPA (Caddy), port 3001 is the API directly (for debugging).
- The MCP endpoint requires a valid API key (separate from JWT auth). - The MCP endpoint requires a valid API key (separate from JWT auth).
- The root `worker/` directory is legacy. The active worker is `apps/worker`.
+67
View File
@@ -0,0 +1,67 @@
Project E — Full Review, Functional Completion & CI/CD Plan
What we know
Actual stack: Vite + React 19 + TanStack Router/Query + shadcn/ui SPA (apps/web) · Hono/Bun API (apps/api) · Bun worker · Drizzle/Postgres. README/AGENTS.md describe a Next.js app that no longer exists — docs are stale.
Deployment: docker-compose on the projecte host (10.0.0.204): db, api, spa (Caddy), worker. Secrets come from a host .env (already/soon exists). Remote = git.buzzbee.dev (Gitea).
CI/CD: No workflows exist yet. Will add Gitea Actions (.gitea/workflows/) using runner label projecte-runner, deploy = docker compose on the same host, triggered by push to main + manual dispatch.
Phase 0 — CI/CD foundation (do first, so everything after ships through it)
.gitea/workflows/ci.yml with two jobs:
quality (every push + PR): bun install --frozen-lockfile → typecheck (api + worker + web) → vite build → docker compose build (catches Dockerfile breakage early).
deploy (push to main + workflow_dispatch): docker compose build → apply migrations → docker compose up -d → health checks (SPA 200, /api/health DB ping, login smoke via POST /api/auth/credentials).
Deploy job runs on projecte-runner; uses host .env for secrets; keeps previous image tags for rollback.
Migration story — currently broken: db:push doesn't apply drizzle/*.sql, README references a non-existent drizzle/0000_postgres.sql, and 0005 is hand-written. Reconcile: verify the 5 migration files reproduce the .migration-baseline-schema.sql schema, make a single idempotent db:migrate script, and call it in the deploy job before up -d.
Docs refresh: rewrite README, AGENTS.md, DEPLOY.md to describe the real stack, deploy flow, and Gitea Actions pipeline.
Conventions to fix first (they gate everything): implement requireWorkspaceAccess() per AGENTS.md, a shared recordActivity wrapper that always derives workspaceId correctly, and a shared error helper — then phases 1–4 build on them.
Phase 1 — Critical functional fixes (app must stop lying)
# Fix Where
1 Calendar delete — useApiMutation("delete", "", …) → DELETE /api 404. Use DELETE /calendar/events/{id} + wire drag/resize handlers + enable withDragAndDrop calendar.tsx:173
2 Canvas persistence — blocks are local state; handleSave sends only {name}. Add canvas-card CRUD endpoints (API), persist blocks (content/type/order), load on open, save on debounce, route detail page through it canvas.tsx:243, API routes/canvas.ts
3 Logout — no POST /api/auth/logout; cookie never cleared. Add route that clears the session cookie; client already calls it in 3 places API routes/auth.ts, sidebar.tsx
4 Graph edge write/delete 500 — recordActivity({ workspaceId: "" }) → invalid uuid. Derive domain from the edge API routes/graph.ts:150,190
5 Analytics fake data — replace Math.random() with real endpoints; fix /analytics/habits domain scoping; make /analytics/projects real; real CSV export analytics.tsx:137, API routes/analytics.ts
6 Task board DnD — add droppable columns, persist status change + order via API tasks.tsx:215
7 Notes editor — wire installed Tiptap (replace contentEditable): autosave debounce, placeholder fix, stop stopPropagation killing shortcuts, render HTML on detail page (use editor output, not raw innerHTML text) notes.tsx, notes/$id.tsx
8 Passkey security hole — authenticates without verifying signature. Either implement real WebAuthn verification or remove the passkey surface API routes/auth.ts:115
9 Domain/owner scoping (IDOR) — add owner/domain checks to all by-ID GET/PATCH/DELETE routes; scope /api/search and /api/export to active domain all API routes
10 Command palette routing — singular vs plural types → non-existent /search/{id}. Fix type→route map; fix @mention agent fetch (response is {items}, q ignored) command-palette.tsx:313
11 Agent activity SSE handler — wrong event type match + data.payload doesn't exist; would crash on match agents/activity.tsx:54
12 Graph fly-to — centerAt(undefined, undefined) on raw nodes (no x/y). Track positions via onNodeDrag or search the rendered graph data graph.tsx:157
Phase 2 — UX & polish pass (current shadcn look, elevated)
Rebuild login on the design system: Input/Button/Label, autofocus, loading/disabled state, error styling, branding mark. (Worst screen in the app today.)
Feedback everywhere: mount Sonner <Toaster/>; add success/error toasts to every create/update/delete mutation; inline error handling on failed mutations.
State coverage: shared LoadingState (skeleton), EmptyState (icon + text + CTA), ErrorState components; apply to every page (calendar, graph, analytics, settings tabs, search, agents…).
Settings honesty: implement density (CSS variable that actually changes spacing), wire sidebarPos to reposition the sidebar, add real reduce-motion CSS — or remove the controls.
Typography: add a real font via --font-sans (self-hosted or Google Fonts) — currently browser-default only.
Consolidate color maps: one shared src/lib/status-colors.ts (status/priority/entity colors as semantic tokens) replacing 8+ divergent copies; make accent theming actually visible.
Unify detail routes (tasks/$id, habits/$id, projects/$id, notes/$id, canvas/$id) with the app design language; make list UIs link to them.
Dashboard: fix sort=dueDate→due_date (widget shows wrong tasks), align realtime invalidation keys with widget query keys, make widget grid responsive.
Responsive: notes/graph/settings panes on mobile, dashboard grid stacking, calendar height.
A11y: aria-labels on icon/scale buttons, radiogroup for mood/energy, fix dangerouslySetInnerHTML snippet XSS surface (sanitize <mark>), notification badge label.
Phase 3 — Secondary feature completion
Canvas: persist block editor (from Phase 1) + real save UX + delete blocks/cards.
Daily notes: timezone bug (UTC midnight stored, local day shown), add DELETE /api/daily-notes/:id, autosave cleanup, mood/energy before content creates the note.
Graph: domain picker (shared with topbar), node detail → navigable entity links.
Analytics: real charts (tasks completed over time, created vs completed, habit consistency, project progress, time per domain, productivity heatmap) + honest empty states + working CSV.
Domains: active-domain selection store + picker; API already supports multi-domain.
Tags: assign/remove tags on tasks/habits/notes from the UI (currently create-only); fix in-memory tag filter after pagination.
Import/Export: scope export to domain (currently exports every user's data), implement advertised CSV format, validate import.
Custom fields: decide and wire to entities, or hide if inert.
Phase 4 — Advanced features (selected; largest phase)
Webhooks pipeline: enqueue webhook_delivery jobs on entity events; fix POST /webhooks/:id/test; worker already knows how to deliver — just wire the queue.
Recurring tasks: create scheduledJobs from recurrenceRule; worker recurring_spawn already exists — wire creation.
Agents: real CRUD UI, permission editing, activity filters (from/to/action honored server-side).
Notifications: real notification count/feed instead of hardcoded 0.
Reports & Milestones: minimal working versions, or remove from nav until built (recommend: build minimal).
MCP polish: validation, correct error codes, domain scoping, drop the dead ?? sql`` `` `` `` leftover.
Worker: wire ai_dispatch or mark it disabled.
Phase 5 — E2E rewrite + docs
Rewrite the Playwright suite (e2e/*.spec.ts) for the new SPA: auth cookie flow, tasks/habits/projects/notes/calendar/canvas/daily/graph/search/analytics/settings/agents-activity; new auth helper; drop specs for removed features (reports/mcp UI).
CI: dedicated ephemeral Postgres + dev servers for E2E, run chromium only in CI (full 5-browser matrix locally/on demand); npx playwright install --with-deps chromium on the runner.
Update docs/API.md to match reality.
Risks & mitigations
Huge scope → sequenced phases; each phase lands on main and deploys independently, so value ships incrementally.
DB migrations on live prod data → Phase 0 makes migrations idempotent + verifiable before any deploy; backup volume (project-e-pg-data) noted in DEPLOY.md.
E2E against prod → E2E runs in CI against an ephemeral test DB, never prod.
Secrets → host .env (gitignored), never committed; Gitea Actions secrets only if needed later.
DnD persistence + Tiptap are the two most invasive frontend changes → done early (Phase 1) so regressions surface in CI before polish.
Verification
Per user: CI + manual testing — quality job gates every push (typecheck/build/docker build); deploy job gates main; you verify on the live site (10.0.0.204:3000) after each deploy. E2E suite (Phase 5) becomes the automated gate once rewritten.
+115 -205
View File
@@ -1,274 +1,209 @@
# Project E # Project E
A personal project, habit, and task tracker built for the AI-agent era. Track tasks, build habits, manage projects, write notes, generate reports, and let AI agents work alongside you through a native MCP (Model Context Protocol) server. A personal project, habit, and task tracker built for the AI-agent era. Track tasks and habits, manage projects, write notes, and let AI agents read and write your data through a built-in MCP (Model Context Protocol) server.
## Features ## Features
- **Tasks:** Kanban boards, priorities, due dates, subtasks, time tracking, recurring tasks, dependencies, and attachments - **Tasks:** Kanban boards, priorities, due dates, subtasks, time tracking, recurring tasks, dependencies, and attachments
- **Habits:** Daily/weekly/custom frequencies, streak tracking, mood logging, skip days, and completion scoring - **Habits:** Daily/weekly/custom frequencies, streak tracking, mood logging, skip days, and completion scoring
- **Projects:** Organize work by domain, track progress through milestones, set deadlines, and manage team members - **Projects:** Organize work by domain, track progress through milestones, set deadlines, and manage team members
- **Notes:** Rich text editor with wikilinks, note graph visualization, bookmarks, and AI-generated content support - **Notes:** Rich text editor with wikilinks, note graph visualization, and bookmarks
- **Reports:** Weekly, monthly, project, and habit reports with templates and AI-assisted generation - **Reports:** Weekly, monthly, project, and habit reports with templates
- **Milestones:** Plan project phases, set dependencies, and track completion - **Milestones:** Plan project phases, set dependencies, and track completion
- **Domains & Tags:** Organize everything across life domains (work, personal, health) with flexible tagging - **Domains & Tags:** Organize everything across life domains (work, personal, health) with flexible tagging
- **AI Agents:** Register agents with API keys, assign permission tiers, and dispatch work via @mentions - **AI Agents:** Register agents with API keys, assign permission tiers, and dispatch work via @mentions
- **Webhooks:** Subscribe to events, deliver payloads with HMAC signatures, and track delivery history - **Webhooks:** Subscribe to events, deliver payloads with HMAC signatures, and track delivery history
- **Analytics:** Task completion rates, habit consistency, time summaries, and streak tracking - **Analytics:** Task completion rates, habit consistency, time summaries, and streak tracking
- **Realtime:** Server-sent events proxy keeps the UI in sync across devices - **Realtime:** SSE feed backed by PostgreSQL LISTEN/NOTIFY keeps the UI in sync across devices
- **Background Worker:** Processes webhook deliveries, agent mentions, report generation, recurring tasks, and data cleanup - **Background Worker:** Bun process for webhook deliveries, agent mentions, recurring tasks, and data cleanup
- **MCP Server:** 61 tools for AI agents to read and write data through the Model Context Protocol - **MCP Server:** 18 tools for AI agents to read and write data through the Model Context Protocol
## Architecture ## Architecture
``` ```
┌─────────────────────────────────────────────────────────────┐ ┌───────────────────────────────────────────────────────────────┐
│ Frontend (Next.js) │ │ Frontend (Vite SPA) │
│ React 19 · App Router · shadcn/ui · Tailwind · Zustand │ │ React 19 · TanStack Router/Query · shadcn/ui · Tailwind │
└──────────────────────────┬──────────────────────────────────┘ └──────────────────────────┬────────────────────────────────────┘
│ REST API + SSE │ REST API + SSE
┌──────────────────────────▼──────────────────────────────────┐ │ (Vite dev proxy → :3001)
│ API Layer (Next.js Routes) │ ┌──────────────────────────▼────────────────────────────────────┐
│ Auth · Validation (Zod) · Realtime SSE Proxy · MCP Server │ │ API (Hono on Bun, apps/api) │
└──────────────────────────┬──────────────────────────────────┘ │ Auth (JWT) · Routes · Realtime SSE · MCP server · Webhooks │
│ Drizzle ORM └──────────────────────────┬────────────────────────────────────┘
┌──────────────────────────▼──────────────────────────────────┐ │ Drizzle ORM (postgres driver)
│ Data Layer (PostgreSQL + Drizzle ORM) │ ┌──────────────────────────▼────────────────────────────────────┐
│ PostgreSQL · Drizzle migrations · NextAuth credentials │ │ PostgreSQL 16 + Drizzle ORM │
└─────────────────────────────────────────────────────────────┘ │ Schema in packages/db/src/schema.ts · migrations in drizzle/ │
└────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐ ┌────────────────────────────────────────────────────────────────┐
│ Background Worker │ │ Background Worker (Bun, apps/worker) │
│ Webhook Delivery · Agent Mentions · Report Generation │ │ Webhook delivery · Agent mentions · Recurring tasks · │
│ Recurring Tasks · Data Cleanup │ │ Data cleanup │
└─────────────────────────────────────────────────────────────┘ └────────────────────────────────────────────────────────────────┘
``` ```
## Tech Stack ## Tech Stack
| Layer | Technology | | Layer | Technology |
|-------|-----------| |-------|-----------|
| Frontend | Next.js 15, React 19, TypeScript 5.9 | | Frontend | Vite 5, React 19, TypeScript 5.9 |
| Routing / Data | TanStack Router, TanStack Query, TanStack Table |
| UI Components | shadcn/ui, Radix UI, Lucide icons | | UI Components | shadcn/ui, Radix UI, Lucide icons |
| Styling | Tailwind CSS 3.4, tailwind-merge, class-variance-authority | | Styling | Tailwind CSS 3.4, tailwind-merge, class-variance-authority |
| State Management | Zustand 5 | | State Management | Zustand 5 |
| Rich Text | Tiptap 3 | | Rich Text | Tiptap 3 |
| Forms | React Hook Form 7, Zod 4 validation | | Forms | React Hook Form, Zod 4 validation |
| Calendar | react-big-calendar, date-fns | | Calendar | react-big-calendar, date-fns |
| Charts | Recharts 3 | | Charts | Recharts 3 |
| Graph Visualization | react-force-graph-2d | | Graph Visualization | react-force-graph-2d |
| Drag & Drop | @dnd-kit | | Drag & Drop | @dnd-kit |
| Backend | Next.js API routes (App Router) | | Backend | Hono 4 on Bun 1.3 (`apps/api`) |
| Database | PostgreSQL 16 with Drizzle ORM | | Database | PostgreSQL 16 with Drizzle ORM and the `postgres` driver |
| Authentication | NextAuth 4 with credentials authentication | | Authentication | JWT (jose) in an httpOnly session cookie; API keys for agents |
| Background Jobs | Node.js worker with polling and exponential backoff | | Background Jobs | Bun worker (`apps/worker`) |
| MCP Server | @modelcontextprotocol/sdk 1.29 | | MCP Server | JSON-RPC over HTTP at `/api/mcp`, served by the API |
| Monorepo | Turborepo 2.5, npm workspaces | | Monorepo | Turborepo 2.5, Bun workspaces |
| Testing | Jest (unit/component), Playwright 1.61 (E2E) | | Testing | Playwright (E2E in `e2e/`) |
## Prerequisites ## Prerequisites
- **Node.js** 22.13.0 or later - **Bun** 1.3.14 or later (the repo pins `bun@1.3.14`)
- **npm** 10.0.0 or later - **PostgreSQL** 16 (local install, or the Docker container from the compose file)
- **PostgreSQL** 16 or later - **Docker + Docker Compose** for the production stack (see [DEPLOY.md](DEPLOY.md))
## Quick Start ## Quick Start
### Development Setup
1. **Clone the repository** 1. **Clone the repository**
```bash ```bash
git clone <repository-url> git clone https://git.buzzbee.dev/Vibing/ProjectE.git
cd ProjectE cd ProjectE
``` ```
2. **Install dependencies** 2. **Install dependencies**
```bash ```bash
npm install bun install
``` ```
3. **Create the database** 3. **Start PostgreSQL**
```bash ```bash
createuser -P project_e docker compose up -d db
createdb -O project_e project_e ```
```
Or point `DATABASE_URL` at an existing PostgreSQL 16 instance.
4. **Set environment variables** 4. **Set environment variables**
Create a `.env.local` file in the root: ```bash
cp .env.example .env
```
```bash Fill in `DATABASE_URL`, `POSTGRES_PASSWORD`, `AUTH_SECRET`, `INITIAL_ADMIN_EMAIL`, and `INITIAL_ADMIN_PASSWORD`.
DATABASE_URL=postgresql://project_e:your_postgres_password@localhost:5432/project_e
POSTGRES_PASSWORD=your_postgres_password
NEXTAUTH_SECRET=your_long_random_secret
INITIAL_ADMIN_EMAIL=admin@example.com
INITIAL_ADMIN_PASSWORD=your_initial_admin_password
```
`INITIAL_ADMIN_EMAIL` and `INITIAL_ADMIN_PASSWORD` create the first admin account when you sign in with those credentials.
5. **Apply the database schema** 5. **Apply the database schema**
```bash
psql "postgresql://project_e:your_postgres_password@localhost:5432/project_e" -f drizzle/0000_postgres.sql
```
6. **Start the development server**
```bash ```bash
npm run dev bun run db:migrate
``` ```
This starts all packages via Turborepo: Pushes the schema (`drizzle-kit push --force`) and applies the search-vector triggers. Idempotent, safe to re-run.
- Web app at `http://localhost:3000`
- Worker (if configured) 6. **Start the development servers**
```bash
bun run dev
```
- API (Hono) on `http://localhost:3001`
- Web (Vite) on `http://localhost:3000`, proxying `/api` and `/mcp` to :3001
7. **Sign in as the initial admin** 7. **Sign in as the initial admin**
Open `http://localhost:3000` and sign in with `INITIAL_ADMIN_EMAIL` and `INITIAL_ADMIN_PASSWORD`. Open `http://localhost:3000` and sign in with `INITIAL_ADMIN_EMAIL` and `INITIAL_ADMIN_PASSWORD`. The first login creates the admin account.
## Project Structure ## Project Structure
``` ```
project-e/ project-e/
├── apps/ ├── apps/
│ └── web/ # Next.js application │ ├── api/ # Hono + Bun API server (routes, middleware, auth)
│ ├── app/ # App Router pages and API routes │ ├── web/ # Vite + React 19 SPA (TanStack Router, shadcn/ui)
│ │ ├── (auth)/ # Auth pages (login, signup) │ ├── worker/ # Bun background worker (src/index.ts)
│ │ ├── (dashboard)/ # Dashboard pages │ └── web-legacy/ # Old Next.js app, kept for reference only
│ │ ├── api/ # REST API endpoints
│ │ │ ├── auth/ # Login, logout, refresh, me
│ │ │ ├── tasks/ # Task CRUD + bulk operations
│ │ │ ├── habits/ # Habit CRUD
│ │ │ ├── projects/ # Project CRUD
│ │ │ ├── notes/ # Note CRUD
│ │ │ ├── reports/ # Report CRUD
│ │ │ ├── milestones/ # Milestone CRUD
│ │ │ ├── domains/ # Domain CRUD
│ │ │ ├── tags/ # Tag CRUD
│ │ │ ├── agents/ # Agent CRUD
│ │ │ ├── webhooks/ # Webhook CRUD
│ │ │ ├── analytics/ # Analytics data
│ │ │ ├── realtime/ # SSE updates
│ │ │ ├── mcp/ # MCP server endpoint
│ │ │ └── health/ # Health check
│ │ └── layout.tsx # Root layout
│ ├── components/ # React components (shadcn/ui)
│ ├── hooks/ # Custom React hooks
│ ├── lib/ # Utilities, services, and NextAuth config
│ │ ├── mcp/ # MCP server and tools
│ │ ├── services/ # Business logic services
│ │ ├── stores/ # Zustand stores
│ │ ├── events/ # Event bus
│ │ ├── auth-config.ts # NextAuth configuration
│ │ └── errors.ts # Error handling
│ └── types/ # TypeScript type definitions
├── packages/ ├── packages/
│ ├── db/ # Drizzle schema and PostgreSQL client │ ├── db/ # Drizzle schema, client, and ORM access
│ └── shared/ # Shared package │ └── shared/ # Shared types, schemas, and constants
│ └── src/ ├── drizzle/ # Drizzle migrations (0000–0005)
│ ├── schemas/ # Zod validation schemas ├── script/ # deploy.sh, apply-triggers.ts
│ ├── types/ # TypeScript types ├── e2e/ # Playwright E2E tests
│ └── constants/ # Shared constants ├── .gitea/workflows/ # Gitea Actions CI/CD (ci.yml)
├── drizzle/ # Generated PostgreSQL migrations ├── docker-compose.yml # Production stack (db, api, spa, worker)
├── worker/ ├── Caddyfile # SPA serving + /api reverse proxy
│ └── index.ts # Background job worker ├── Dockerfile.api # API image
├── e2e/ # Playwright E2E tests ├── Dockerfile.spa # SPA build + Caddy image
├── tests/ # Unit and component tests ├── Dockerfile.worker # Worker image
├── drizzle.config.ts # Drizzle Kit configuration ├── bunfig.toml
├── turbo.json # Turborepo configuration ├── drizzle.config.ts
└── package.json # Root package.json └── package.json
``` ```
## Available Scripts ## Available Scripts
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
| `npm run dev` | Start all packages in development mode | | `bun run dev` | Run the API (watch) and the Vite dev server concurrently |
| `npm run build` | Build all packages for production | | `bun run dev:api` | API server with watch on :3001 |
| `npm run lint` | Run linting across all packages | | `bun run dev:web` | Vite dev server on :3000 |
| `npm run test` | Run unit and component tests (Jest) | | `bun run build` | Production build of the web SPA (`vite build`) |
| `npm run test:e2e` | Run Playwright E2E tests | | `bun run typecheck` | `tsc --noEmit` across api, worker, and web |
| `npm run test:e2e:ui` | Run Playwright tests with UI mode | | `bun run db:push` | Push schema changes to the database (`drizzle-kit push`) |
| `npm run test:e2e:report` | Show Playwright test report | | `bun run db:sync` | Push schema with `--force` (idempotent) |
| `npm run typecheck` | Run TypeScript type checking | | `bun run db:generate` | Generate a new Drizzle migration from schema changes |
| `npm run db:generate` | Generate Drizzle migrations | | `bun run db:triggers` | Apply the search-vector triggers (idempotent) |
| `bun run db:migrate` | `db:sync` + `db:triggers`; what deploys run |
| `bun run db:studio` | Open Drizzle Studio |
| `bun run deploy` | Run `script/deploy.sh` (see DEPLOY.md) |
## Environment Variables ## Environment Variables
Variables live in a root `.env` file (not `.env.local`). Copy from `.env.example`.
| Variable | Description | Default | | Variable | Description | Default |
|----------|-------------|---------| |----------|-------------|---------|
| `DATABASE_URL` | PostgreSQL connection string | (required) | | `DATABASE_URL` | PostgreSQL connection string | (required) |
| `POSTGRES_PASSWORD` | Password for the `project_e` PostgreSQL user | (required) | | `POSTGRES_PASSWORD` | Password for the `project_e` user (used by docker-compose) | (required) |
| `NEXTAUTH_SECRET` | Secret used to sign NextAuth sessions | (required) | | `AUTH_SECRET` | Secret that signs JWT sessions (`NEXTAUTH_SECRET` is accepted as a fallback) | (required) |
| `INITIAL_ADMIN_EMAIL` | Email for the account created on first sign-in | (required) | | `INITIAL_ADMIN_EMAIL` | Email for the account created on first sign-in | (required) |
| `INITIAL_ADMIN_PASSWORD` | Password for the account created on first sign-in | (required) | | `INITIAL_ADMIN_PASSWORD` | Password for the account created on first sign-in | (required) |
| `NODE_ENV` | Environment (`development`, `production`) | `development` | | `NODE_ENV` | `development` or `production` | `development` |
| `PUBLIC_URL` | Absolute URL used for emails and webhooks | `http://localhost:3000` |
Create a `.env.local` file in the root directory for local development. | `COOKIE_SECURE` | Set `true` behind HTTPS | `false` |
| `ALLOWED_HOSTS` | Comma-separated list of allowed hostnames | `localhost` |
## Testing ## Testing
### Unit and Component Tests Playwright E2E tests live in `e2e/` (config at the repo root). Start the dev stack (`bun run dev`) in one terminal, then run:
```bash ```bash
npm run test # Full suite
bunx playwright test
# Interactive UI mode
bunx playwright test --ui
# Plain list reporter
bunx playwright test --reporter=list
``` ```
Runs Jest tests across all packages. Tests are located in `tests/` and alongside components. Tests run against Chromium, Firefox, WebKit, Mobile Chrome, and Mobile Safari, and cover authentication, tasks, habits, projects, notes, reports, analytics, calendar, dashboard, search, settings, webhooks, realtime, MCP, and import/export.
### E2E Tests
```bash
# Run all E2E tests
npm run test:e2e
# Run with UI mode (interactive)
npm run test:e2e:ui
# View test report
npm run test:e2e:report
```
Playwright tests are in `e2e/` and cover:
- Authentication flows
- Task management
- Habit tracking
- Project organization
- Note editing
- Report generation
- Analytics dashboards
- Navigation and settings
Tests run against five browser configurations: Chromium, Firefox, WebKit, Mobile Chrome, and Mobile Safari.
## Deployment ## Deployment
### Environment Configuration Production runs as a docker-compose stack (db, api, spa, worker) on the host. Gitea Actions drives deploys: a `quality` gate runs on every push and PR, a `deploy` job on pushes to `main` runs `script/deploy.sh`, and a `smoke` job verifies health afterwards. Manual steps, rollback, and troubleshooting are in [DEPLOY.md](DEPLOY.md).
Provision PostgreSQL, apply `drizzle/0000_postgres.sql`, and set these in your deployment environment:
```bash
DATABASE_URL=postgresql://project_e:your_postgres_password@your-postgres-host:5432/project_e
POSTGRES_PASSWORD=your_postgres_password
NEXTAUTH_SECRET=your_long_random_secret
INITIAL_ADMIN_EMAIL=admin@example.com
INITIAL_ADMIN_PASSWORD=your_initial_admin_password
```
Start the web app and worker after the database is available:
```bash
npm run build
npm run --workspace @project-e/web start
npm run --workspace @project-e/worker start
```
### Health Checks
Use `GET /api/health` to check the web app.
## Documentation ## Documentation
@@ -280,36 +215,11 @@ Use `GET /api/health` to check the web app.
## Contributing ## Contributing
1. Fork the repository 1. Fork the repository on Gitea and create a feature branch
2. Create a feature branch (`git checkout -b feature/amazing-feature`) 2. Make your changes
3. Make your changes 3. Run `bun run typecheck` and `cd apps/web && bun run build` before committing
4. Run tests (`npm run test && npm run test:e2e`) 4. Push the branch and open a pull request. CI runs the same quality gate on every push and PR.
5. Commit your changes (`git commit -m 'Add amazing feature'`)
6. Push to the branch (`git push origin feature/amazing-feature`)
7. Open a Pull Request
### Development Guidelines
- Write tests for new features
- Follow existing code style (TypeScript, functional components)
- Update documentation for API changes
- Keep commits atomic and well-described
- Use conventional commit messages
### Code Review Process
- All PRs require at least one review
- CI must pass (lint, typecheck, tests)
- Keep PRs focused on a single concern
- Write clear PR descriptions explaining the "why"
## License ## License
This project is private and proprietary. This project is private and proprietary.
## Support
For issues and questions:
- Open an issue on GitHub
- Check the documentation in `docs/`
- Review existing issues for similar problems
+1
View File
@@ -15,6 +15,7 @@
"hono": "^4.6.0", "hono": "^4.6.0",
"jose": "^5.9.6", "jose": "^5.9.6",
"postgres": "^3.4.9", "postgres": "^3.4.9",
"rrule": "^2.8.1",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
+2
View File
@@ -23,6 +23,7 @@ import { customFieldRoutes } from "./routes/custom-fields";
import { errorLogRoutes } from "./routes/error-log"; import { errorLogRoutes } from "./routes/error-log";
import { analyticsRoutes } from "./routes/analytics"; import { analyticsRoutes } from "./routes/analytics";
import { importExportRoutes } from "./routes/import-export"; import { importExportRoutes } from "./routes/import-export";
import { notificationRoutes } from "./routes/notifications";
import { healthHandler } from "./routes/health"; import { healthHandler } from "./routes/health";
const app = new Hono(); const app = new Hono();
@@ -57,6 +58,7 @@ app.route("/api/tags", tagRoutes);
app.route("/api/custom-fields", customFieldRoutes); app.route("/api/custom-fields", customFieldRoutes);
app.route("/api/error-log", errorLogRoutes); app.route("/api/error-log", errorLogRoutes);
app.route("/api/analytics", analyticsRoutes); app.route("/api/analytics", analyticsRoutes);
app.route("/api/notifications", notificationRoutes);
app.route("/api", importExportRoutes); app.route("/api", importExportRoutes);
app.route("/api", realtimeRoutes); app.route("/api", realtimeRoutes);
app.route("/api/mcp", mcpRoutes); app.route("/api/mcp", mcpRoutes);
+94 -1
View File
@@ -1,4 +1,21 @@
import { db, sql, activityFeed } from "@project-e/db"; import {
db,
sql,
activityFeed,
tasks,
habits,
projects,
notes,
canvases,
dailyNotes,
calendarEvents,
webhooks,
agents,
customFields,
dashboardWidgets,
} from "@project-e/db";
import { eq } from "drizzle-orm";
import type { AnyPgColumn, AnyPgTable } from "drizzle-orm/pg-core";
export interface RecordActivityParams { export interface RecordActivityParams {
actor: string; actor: string;
@@ -24,3 +41,79 @@ export async function recordActivity(params: RecordActivityParams): Promise<void
const payload = JSON.stringify({ type: entityType, action, id: entityId, workspace_id: workspaceId }); const payload = JSON.stringify({ type: entityType, action, id: entityId, workspace_id: workspaceId });
await sql`SELECT pg_notify('project_e_events', ${payload}::text)`; await sql`SELECT pg_notify('project_e_events', ${payload}::text)`;
} }
// ── recordActivityForEntity ────────────────────────────────────────────────────
// Variant that derives the workspaceId from the entity itself when the caller
// omits it. Keeps `recordActivity` unchanged so existing call sites compile as-is.
export interface RecordActivityForEntityParams {
actor: string;
action: string;
entityType: string;
entityId: string;
changes?: Record<string, unknown>;
workspaceId?: string | null;
}
interface EntityWorkspaceLookup {
table: AnyPgTable;
idColumn: AnyPgColumn;
workspaceColumn: AnyPgColumn;
}
// Maps an entityType (as recorded in activity_feed) to the table + column that
// holds its owning workspace/domain. Tables with domain_id use that; webhooks
// store it as workspace_id.
const entityWorkspaceLookups: Record<string, EntityWorkspaceLookup> = {
task: { table: tasks, idColumn: tasks.id, workspaceColumn: tasks.domainId },
habit: { table: habits, idColumn: habits.id, workspaceColumn: habits.domainId },
project: { table: projects, idColumn: projects.id, workspaceColumn: projects.domainId },
note: { table: notes, idColumn: notes.id, workspaceColumn: notes.domainId },
canvas: { table: canvases, idColumn: canvases.id, workspaceColumn: canvases.domainId },
daily_note: { table: dailyNotes, idColumn: dailyNotes.id, workspaceColumn: dailyNotes.domainId },
calendar_event: { table: calendarEvents, idColumn: calendarEvents.id, workspaceColumn: calendarEvents.domainId },
webhook: { table: webhooks, idColumn: webhooks.id, workspaceColumn: webhooks.workspaceId },
agent: { table: agents, idColumn: agents.id, workspaceColumn: agents.domainId },
custom_field: { table: customFields, idColumn: customFields.id, workspaceColumn: customFields.domainId },
dashboard_widget: { table: dashboardWidgets, idColumn: dashboardWidgets.id, workspaceColumn: dashboardWidgets.domainId },
};
async function resolveEntityWorkspaceId(entityType: string, entityId: string): Promise<string | null> {
const lookup = entityWorkspaceLookups[entityType];
if (!lookup) return null;
const [row] = await db
.select({ workspaceId: lookup.workspaceColumn })
.from(lookup.table)
.where(eq(lookup.idColumn, entityId))
.limit(1);
// AnyPgColumn erases the concrete type, so the selected value is `unknown`.
return (row?.workspaceId as string | undefined) ?? null;
}
/**
* Record an activity event, deriving the workspaceId from the entity when not
* provided (or empty). Unknown entity types or unresolvable entities are logged
* and skipped rather than crashing the request.
*/
export async function recordActivityForEntity(params: RecordActivityForEntityParams): Promise<void> {
let workspaceId = params.workspaceId;
if (!workspaceId || workspaceId.trim() === "") {
workspaceId = await resolveEntityWorkspaceId(params.entityType, params.entityId);
if (!workspaceId) {
console.warn(`[activity] Could not resolve workspace for ${params.entityType}:${params.entityId}; skipping activity`);
return;
}
}
await recordActivity({
actor: params.actor,
action: params.action,
entityType: params.entityType,
entityId: params.entityId,
changes: params.changes,
workspaceId,
});
}
+39 -5
View File
@@ -2,8 +2,8 @@ import { createMiddleware } from "hono/factory";
import type { Context, Next } from "hono"; import type { Context, Next } from "hono";
import { jwtVerify, SignJWT } from "jose"; import { jwtVerify, SignJWT } from "jose";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { db, users, apiKeys } from "@project-e/db"; import { db, users, apiKeys, domains } from "@project-e/db";
import { and, eq } from "drizzle-orm"; import { and, asc, eq } from "drizzle-orm";
const AUTH_SECRET = new TextEncoder().encode(process.env.AUTH_SECRET || process.env.NEXTAUTH_SECRET || "fallback-secret-change-me"); const AUTH_SECRET = new TextEncoder().encode(process.env.AUTH_SECRET || process.env.NEXTAUTH_SECRET || "fallback-secret-change-me");
const COOKIE_NAME = "session"; const COOKIE_NAME = "session";
@@ -101,10 +101,44 @@ export async function requireAuth(c: Context): Promise<AuthUser> {
return user; return user;
} }
export async function resolveActiveDomain(user: { id: string; email: string; name?: string | null }): Promise<{ id: string; name: string; created: boolean }> { /**
const { domains } = await import("@project-e/db"); * Require access to a workspace (domain). Verifies the workspace exists and,
const { asc } = await import("drizzle-orm"); * when a user is present on the context, that the user owns it.
*
* Ownership fallback: domain rows created before the ownership model was
* introduced may have a NULL ownerId. For those rows we fall back to the
* existence check only, so legacy data isn't locked out.
*
* @returns the domain row so callers can reuse it (id, name, slug, ownerId, …)
* @throws AuthError 403 FORBIDDEN when workspaceId is missing/empty or not owned
* @throws AuthError 404 NOT_FOUND when no such workspace exists
*/
export async function requireWorkspaceAccess(c: Context, workspaceId: string): Promise<typeof domains.$inferSelect> {
if (!workspaceId || workspaceId.trim() === "") {
throw new AuthError("Workspace ID is required", 403, "FORBIDDEN");
}
const [domain] = await db
.select()
.from(domains)
.where(eq(domains.id, workspaceId))
.limit(1);
if (!domain) {
throw new AuthError("Workspace not found", 404, "NOT_FOUND");
}
const user = c.get("user");
// Single-user/personal app: ownership is enforced when a user is known.
// Rows with NULL ownerId (legacy) are allowed through the existence check above.
if (user && domain.ownerId !== null && domain.ownerId !== user.id) {
throw new AuthError("You do not have access to this workspace", 403, "FORBIDDEN");
}
return domain;
}
export async function resolveActiveDomain(user: { id: string; email: string; name?: string | null }): Promise<{ id: string; name: string; created: boolean }> {
const [existing] = await db const [existing] = await db
.select({ id: domains.id, name: domains.name }) .select({ id: domains.id, name: domains.name })
.from(domains) .from(domains)
+84
View File
@@ -0,0 +1,84 @@
import { db, jobs, webhooks } from "@project-e/db";
import { and, eq } from "drizzle-orm";
/**
* Enqueue a single webhook delivery job for a specific webhook. Used directly by
* the test endpoint and by `enqueueWebhooks` for every matching webhook. The
* worker reads this job, signs the payload with the webhook secret, delivers it
* via fetch, and records the delivery row.
*/
export async function enqueueWebhookDelivery({
webhookId,
event,
entityType,
entityId,
data,
workspaceId,
}: {
webhookId: string;
event: string;
entityType: string;
entityId: string;
data?: Record<string, unknown>;
workspaceId: string;
}): Promise<void> {
await db.insert(jobs).values({
type: "webhook_delivery",
payload: {
webhook_id: webhookId,
event,
entity_type: entityType,
entity_id: entityId,
data: data ?? {},
timestamp: new Date().toISOString(),
workspace_id: workspaceId,
},
status: "pending",
});
}
/**
* Enqueue webhook deliveries for every active webhook in a workspace that
* subscribes to the given event. Never throws — failures are logged and the
* caller's request proceeds, matching the activity-feed behavior.
*/
export async function enqueueWebhooks({
workspaceId,
event,
entityType,
entityId,
data,
}: {
workspaceId: string;
event: string;
entityType: string;
entityId: string;
data?: Record<string, unknown>;
}): Promise<void> {
try {
const activeWebhooks = await db.select()
.from(webhooks)
.where(and(eq(webhooks.workspaceId, workspaceId), eq(webhooks.active, true)));
const matches = activeWebhooks.filter((webhook) =>
(webhook.events ?? []).includes(event)
);
for (const webhook of matches) {
await enqueueWebhookDelivery({
webhookId: webhook.id,
event,
entityType,
entityId,
data,
workspaceId,
});
}
if (matches.length > 0) {
console.log(`[webhooks] Enqueued ${matches.length} delivery job(s) for ${entityType}.${event}`);
}
} catch (error) {
console.error(`[webhooks] Failed to enqueue webhook deliveries for ${entityType}.${event}:`, error);
}
}
+102 -10
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { db, agents, agentActivity, agentTasks } from "@project-e/db"; import { db, agents, agentActivity, agentTasks } from "@project-e/db";
import { and, asc, desc, eq, isNull, sql } from "drizzle-orm"; import { and, asc, desc, eq, gte, ilike, isNull, lte, sql } from "drizzle-orm";
import { requireAuth, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth"; import { requireAuth, createErrorResponse, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth";
import { recordActivity } from "../middleware/activity"; import { recordActivity } from "../middleware/activity";
import { z } from "zod"; import { z } from "zod";
@@ -30,6 +30,33 @@ const updateAgentSchema = z.object({
customFields: z.record(z.string(), z.unknown()).optional(), customFields: z.record(z.string(), z.unknown()).optional(),
}); });
// ── Activity filter helpers ──────────────────────────────────────────────────
// GET /activity and GET /:id/activity honor the `action`, `from`, `to` and
// `limit` query params the frontend activity page sends. Invalid dates are
// ignored rather than erroring; a bare "YYYY-MM-DD" bounds the whole day for
// the `to` filter so a date-picker value doesn't silently drop that day.
function parseActivityDate(value: string): Date | null {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) date.setUTCHours(23, 59, 59, 999);
return date;
}
function parseActivityFilters(c: any): { conditions: any[]; limit: number } {
const conditions: any[] = [];
const action = c.req.query("action");
const from = c.req.query("from");
const to = c.req.query("to");
if (action) conditions.push(eq(agentActivity.action, action));
const fromDate = from ? parseActivityDate(from) : null;
if (fromDate) conditions.push(gte(agentActivity.createdAt, fromDate));
const toDate = to ? parseActivityDate(to) : null;
if (toDate) conditions.push(lte(agentActivity.createdAt, toDate));
const limit = Math.min(Math.max(parseInt(c.req.query("limit") || "100", 10) || 100, 1), 500);
return { conditions, limit };
}
// GET /api/agents — List agents // GET /api/agents — List agents
agentRoutes.get("/", async (c) => { agentRoutes.get("/", async (c) => {
try { try {
@@ -38,13 +65,16 @@ agentRoutes.get("/", async (c) => {
const page = Math.max(1, parseInt(url.searchParams.get("page") || "1")); const page = Math.max(1, parseInt(url.searchParams.get("page") || "1"));
const perPage = Math.min(100, Math.max(1, parseInt(url.searchParams.get("perPage") || "50"))); const perPage = Math.min(100, Math.max(1, parseInt(url.searchParams.get("perPage") || "50")));
const sort = url.searchParams.get("sort") || "-created"; const sort = url.searchParams.get("sort") || "-created";
const q = url.searchParams.get("q")?.trim();
let domainId = url.searchParams.get("domain") || undefined; let domainId = url.searchParams.get("domain") || undefined;
if (!domainId) { if (!domainId) {
const active = await resolveActiveDomain(user); const active = await resolveActiveDomain(user);
domainId = active.id; domainId = active.id;
} }
await requireWorkspaceAccess(c, domainId);
const conditions: any[] = [eq(agents.domainId, domainId)]; const conditions: any[] = [eq(agents.domainId, domainId)];
if (q) conditions.push(ilike(agents.name, `%${q}%`));
const sortField = sort.replace(/^-/, ""); const sortField = sort.replace(/^-/, "");
const sortDir = sort.startsWith("-") ? "desc" : "asc"; const sortDir = sort.startsWith("-") ? "desc" : "asc";
const sortColumns: Record<string, any> = { created: agents.createdAt, updated: agents.updatedAt, name: agents.name }; const sortColumns: Record<string, any> = { created: agents.createdAt, updated: agents.updatedAt, name: agents.name };
@@ -73,6 +103,8 @@ agentRoutes.post("/", async (c) => {
domain: body.domain || (await resolveActiveDomain(user)).id, domain: body.domain || (await resolveActiveDomain(user)).id,
}); });
await requireWorkspaceAccess(c, data.domain);
const [agent] = await db.insert(agents).values({ const [agent] = await db.insert(agents).values({
name: data.name, name: data.name,
description: data.description ?? null, description: data.description ?? null,
@@ -104,11 +136,27 @@ agentRoutes.post("/", async (c) => {
// GET /api/agents/activity — All activity (bare path, no agent filter) // GET /api/agents/activity — All activity (bare path, no agent filter)
agentRoutes.get("/activity", async (c) => { agentRoutes.get("/activity", async (c) => {
try { try {
await requireAuth(c); const user = await requireAuth(c);
const items = await db.select() // agent_activity has no domain_id — scope through the owning agent
const domainId = c.req.query("domain") || (await resolveActiveDomain(user)).id;
await requireWorkspaceAccess(c, domainId);
const { conditions: filterConditions, limit } = parseActivityFilters(c);
const items = await db.select({
id: agentActivity.id,
agentId: agentActivity.agentId,
action: agentActivity.action,
entityType: agentActivity.entityType,
entityId: agentActivity.entityId,
details: agentActivity.details,
success: agentActivity.success,
errorMessage: agentActivity.errorMessage,
createdAt: agentActivity.createdAt,
})
.from(agentActivity) .from(agentActivity)
.innerJoin(agents, eq(agentActivity.agentId, agents.id))
.where(and(eq(agents.domainId, domainId), ...filterConditions))
.orderBy(desc(agentActivity.createdAt)) .orderBy(desc(agentActivity.createdAt))
.limit(100); .limit(limit);
return c.json({ items, totalItems: items.length }); return c.json({ items, totalItems: items.length });
} catch (error) { } catch (error) {
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any); if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
@@ -131,6 +179,7 @@ agentRoutes.get("/:id", async (c) => {
} }
const [agent] = await db.select().from(agents).where(eq(agents.id, id)).limit(1); const [agent] = await db.select().from(agents).where(eq(agents.id, id)).limit(1);
if (!agent) return c.json({ error: { code: "NOT_FOUND", message: "Agent not found" } }, 404); if (!agent) return c.json({ error: { code: "NOT_FOUND", message: "Agent not found" } }, 404);
await requireWorkspaceAccess(c, agent.domainId);
return c.json(agent); return c.json(agent);
} catch (error) { } catch (error) {
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any); if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
@@ -150,6 +199,8 @@ agentRoutes.patch("/:id", async (c) => {
const [existing] = await db.select().from(agents).where(eq(agents.id, id)).limit(1); const [existing] = await db.select().from(agents).where(eq(agents.id, id)).limit(1);
if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Agent not found" } }, 404); if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Agent not found" } }, 404);
await requireWorkspaceAccess(c, existing.domainId);
const updateValues: Record<string, unknown> = {}; const updateValues: Record<string, unknown> = {};
if (data.name !== undefined) updateValues.name = data.name; if (data.name !== undefined) updateValues.name = data.name;
if (data.description !== undefined) updateValues.description = data.description; if (data.description !== undefined) updateValues.description = data.description;
@@ -185,6 +236,8 @@ agentRoutes.delete("/:id", async (c) => {
const [existing] = await db.select().from(agents).where(eq(agents.id, id)).limit(1); const [existing] = await db.select().from(agents).where(eq(agents.id, id)).limit(1);
if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Agent not found" } }, 404); if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Agent not found" } }, 404);
await requireWorkspaceAccess(c, existing.domainId);
await db.delete(agents).where(eq(agents.id, id)); await db.delete(agents).where(eq(agents.id, id));
await recordActivity({ await recordActivity({
@@ -211,6 +264,10 @@ agentRoutes.post("/:id/permissions", async (c) => {
customPermissions: z.array(z.string()).optional().default([]), customPermissions: z.array(z.string()).optional().default([]),
}).parse(body); }).parse(body);
const [existing] = await db.select().from(agents).where(eq(agents.id, id)).limit(1);
if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Agent not found" } }, 404);
await requireWorkspaceAccess(c, existing.domainId);
const [updated] = await db.update(agents) const [updated] = await db.update(agents)
.set({ permissionTier, customPermissions: customPermissions ?? [], updatedAt: new Date() }) .set({ permissionTier, customPermissions: customPermissions ?? [], updatedAt: new Date() })
.where(eq(agents.id, id)) .where(eq(agents.id, id))
@@ -236,11 +293,12 @@ agentRoutes.get("/:id/permissions", async (c) => {
await requireAuth(c); await requireAuth(c);
const id = c.req.param("id"); const id = c.req.param("id");
const [agent] = await db.select({ const [agent] = await db.select({
id: agents.id, permissionTier: agents.permissionTier, customPermissions: agents.customPermissions, id: agents.id, permissionTier: agents.permissionTier, customPermissions: agents.customPermissions, domainId: agents.domainId,
}).from(agents).where(eq(agents.id, id)).limit(1); }).from(agents).where(eq(agents.id, id)).limit(1);
if (!agent) return c.json({ error: { code: "NOT_FOUND", message: "Agent not found" } }, 404); if (!agent) return c.json({ error: { code: "NOT_FOUND", message: "Agent not found" } }, 404);
return c.json(agent); await requireWorkspaceAccess(c, agent.domainId);
return c.json({ id: agent.id, permissionTier: agent.permissionTier, customPermissions: agent.customPermissions });
} catch (error) { } catch (error) {
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any); if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
console.error("[agents] GET /:id/permissions error:", error); console.error("[agents] GET /:id/permissions error:", error);
@@ -251,13 +309,47 @@ agentRoutes.get("/:id/permissions", async (c) => {
// GET /api/agents/:id/activity — Agent activity log (or all if id=_all) // GET /api/agents/:id/activity — Agent activity log (or all if id=_all)
agentRoutes.get("/:id/activity", async (c) => { agentRoutes.get("/:id/activity", async (c) => {
try { try {
await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); const id = c.req.param("id");
if (id === "_all") {
// agent_activity has no domain_id — scope through the owning agent
const domainId = c.req.query("domain") || (await resolveActiveDomain(user)).id;
await requireWorkspaceAccess(c, domainId);
const { conditions: filterConditions, limit } = parseActivityFilters(c);
const items = await db.select({
id: agentActivity.id,
agentId: agentActivity.agentId,
action: agentActivity.action,
entityType: agentActivity.entityType,
entityId: agentActivity.entityId,
details: agentActivity.details,
success: agentActivity.success,
errorMessage: agentActivity.errorMessage,
createdAt: agentActivity.createdAt,
})
.from(agentActivity)
.innerJoin(agents, eq(agentActivity.agentId, agents.id))
.where(and(eq(agents.domainId, domainId), ...filterConditions))
.orderBy(desc(agentActivity.createdAt))
.limit(limit);
return c.json({ items, totalItems: items.length });
}
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Agent not found" } }, 404);
}
const [agent] = await db.select().from(agents).where(eq(agents.id, id)).limit(1);
if (!agent) return c.json({ error: { code: "NOT_FOUND", message: "Agent not found" } }, 404);
await requireWorkspaceAccess(c, agent.domainId);
const { conditions: filterConditions, limit } = parseActivityFilters(c);
const items = await db.select() const items = await db.select()
.from(agentActivity) .from(agentActivity)
.where(id === "_all" ? undefined : eq(agentActivity.agentId, id)) .where(and(eq(agentActivity.agentId, id), ...filterConditions))
.orderBy(desc(agentActivity.createdAt)) .orderBy(desc(agentActivity.createdAt))
.limit(100); .limit(limit);
return c.json({ items, totalItems: items.length }); return c.json({ items, totalItems: items.length });
} catch (error) { } catch (error) {
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any); if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
+125 -20
View File
@@ -1,6 +1,6 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { db, tasks, habits, habitCompletions } from "@project-e/db"; import { db, tasks, habits, habitCompletions, projects } from "@project-e/db";
import { and, eq, gte, isNull } from "drizzle-orm"; import { and, eq, gte, inArray, isNull, or } from "drizzle-orm";
import { requireAuth, resolveActiveDomain, AuthError } from "../middleware/auth"; import { requireAuth, resolveActiveDomain, AuthError } from "../middleware/auth";
export const analyticsRoutes = new Hono(); export const analyticsRoutes = new Hono();
@@ -65,9 +65,17 @@ analyticsRoutes.get("/habits", async (c) => {
.from(habits) .from(habits)
.where(and(eq(habits.domainId, domainId), isNull(habits.deletedAt))); .where(and(eq(habits.domainId, domainId), isNull(habits.deletedAt)));
const allLogs = await db.select() const habitIds = allHabits.map((h) => h.id);
.from(habitCompletions)
.where(gte(habitCompletions.date, startDate)); // Only count completions belonging to habits in this domain (not all completions globally)
const allLogs = habitIds.length > 0
? await db.select()
.from(habitCompletions)
.where(and(
inArray(habitCompletions.habitId, habitIds),
gte(habitCompletions.date, startDate),
))
: [];
const habitConsistency = allHabits.length > 0 const habitConsistency = allHabits.length > 0
? Math.round((allLogs.length / (allHabits.length * range)) * 100) ? Math.round((allLogs.length / (allHabits.length * range)) * 100)
@@ -93,7 +101,7 @@ analyticsRoutes.get("/habits", async (c) => {
} }
}); });
// GET /api/analytics/projects?range=... — Project progress // GET /api/analytics/projects?range=... — Per-project progress
analyticsRoutes.get("/projects", async (c) => { analyticsRoutes.get("/projects", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
@@ -105,24 +113,48 @@ analyticsRoutes.get("/projects", async (c) => {
domainId = active.id; domainId = active.id;
} }
const startDate = new Date(); const allProjects = await db.select()
startDate.setDate(startDate.getDate() - range); .from(projects)
.where(and(eq(projects.domainId, domainId), isNull(projects.deletedAt)));
const allTasks = await db.select() const projectIds = allProjects.map((p) => p.id);
.from(tasks)
.where(and(
eq(tasks.domainId, domainId),
gte(tasks.createdAt, startDate),
isNull(tasks.deletedAt),
));
const completedTasks = allTasks.filter(t => t.status === "done"); // Count tasks per project (any status, including non-done) for the domain
const taskCompletionRate = allTasks.length > 0 ? Math.round((completedTasks.length / allTasks.length) * 100) : 0; const taskRows = projectIds.length > 0
? await db.select({ projectId: tasks.projectId, status: tasks.status })
.from(tasks)
.where(and(
isNull(tasks.deletedAt),
inArray(tasks.projectId, projectIds),
))
: [];
const counts = new Map<string, { totalTasks: number; completedTasks: number }>();
for (const t of taskRows) {
if (!t.projectId) continue;
const entry = counts.get(t.projectId) ?? { totalTasks: 0, completedTasks: 0 };
entry.totalTasks += 1;
if (t.status === "done") entry.completedTasks += 1;
counts.set(t.projectId, entry);
}
const projectsData = allProjects.map((p) => {
const stats = counts.get(p.id) ?? { totalTasks: 0, completedTasks: 0 };
const progress = stats.totalTasks > 0
? Math.round((stats.completedTasks / stats.totalTasks) * 100) / 100
: 0;
return {
id: p.id,
name: p.name,
totalTasks: stats.totalTasks,
completedTasks: stats.completedTasks,
progress,
};
});
return c.json({ return c.json({
taskCompletionRate, projects: projectsData,
totalTasks: allTasks.length, totalProjects: allProjects.length,
completedTasks: completedTasks.length,
period: range, period: range,
}, { }, {
headers: { "Cache-Control": "private, max-age=300, stale-while-revalidate=600" }, headers: { "Cache-Control": "private, max-age=300, stale-while-revalidate=600" },
@@ -133,3 +165,76 @@ analyticsRoutes.get("/projects", async (c) => {
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get project analytics" } }, 500); return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get project analytics" } }, 500);
} }
}); });
// GET /api/analytics/daily?range=... — Daily task creation & completion time series
analyticsRoutes.get("/daily", async (c) => {
try {
const user = await requireAuth(c);
const url = new URL(c.req.url);
const range = parseInt(url.searchParams.get("range") || "30");
let domainId = url.searchParams.get("domain") || undefined;
if (!domainId) {
const active = await resolveActiveDomain(user);
domainId = active.id;
}
// Buckets cover the last `range` days ending today, matching the frontend's expectation.
const firstDay = new Date();
firstDay.setDate(firstDay.getDate() - (range - 1));
firstDay.setHours(0, 0, 0, 0);
const domainTasks = await db.select()
.from(tasks)
.where(and(
eq(tasks.domainId, domainId),
isNull(tasks.deletedAt),
or(
gte(tasks.createdAt, firstDay),
gte(tasks.completedAt, firstDay),
),
));
// Bucket by local calendar date (yyyy-MM-dd) so keys line up with the frontend's
// date-fns day generation (which uses local time as well).
const localDateKey = (d: Date) => {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
};
const createdByDay = new Map<string, number>();
const completedByDay = new Map<string, number>();
for (const t of domainTasks) {
const createdKey = localDateKey(t.createdAt);
createdByDay.set(createdKey, (createdByDay.get(createdKey) || 0) + 1);
if (t.status === "done" && t.completedAt) {
const completedKey = localDateKey(t.completedAt);
completedByDay.set(completedKey, (completedByDay.get(completedKey) || 0) + 1);
}
}
const items: Array<{ date: string; created: number; completed: number }> = [];
const cursor = new Date(firstDay);
for (let i = 0; i < range; i++) {
const key = localDateKey(cursor);
items.push({
date: key,
created: createdByDay.get(key) || 0,
completed: completedByDay.get(key) || 0,
});
cursor.setDate(cursor.getDate() + 1);
}
return c.json({
items,
period: range,
}, {
headers: { "Cache-Control": "private, max-age=300, stale-while-revalidate=600" },
});
} catch (error) {
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
console.error("[analytics] GET /daily error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get daily analytics" } }, 500);
}
});
+13 -74
View File
@@ -1,12 +1,17 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { setCookie } from "hono/cookie"; import { deleteCookie, setCookie } from "hono/cookie";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { db, users } from "@project-e/db"; import { db, users } from "@project-e/db";
import { count, eq } from "drizzle-orm"; import { count, eq } from "drizzle-orm";
import { createToken, requireAuth, createErrorResponse, AuthError } from "../middleware/auth"; import { createToken } from "../middleware/auth";
export const authRoutes = new Hono(); export const authRoutes = new Hono();
// NOTE: Passkeys are intentionally NOT implemented. The legacy passkey routes were
// removed because they issued a session without verifying the WebAuthn signature
// (an authentication bypass). Do not re-add passkey endpoints without full
// WebAuthn challenge/attestation verification.
// POST /api/auth/credentials — Login with email + password // POST /api/auth/credentials — Login with email + password
authRoutes.post("/credentials", async (c) => { authRoutes.post("/credentials", async (c) => {
try { try {
@@ -67,6 +72,12 @@ authRoutes.get("/session", async (c) => {
} }
}); });
// POST /api/auth/logout — Clear the session cookie
authRoutes.post("/logout", (c) => {
deleteCookie(c, "session", { path: "/" });
return c.json({ success: true });
});
// GET /api/auth/me — Return current user profile // GET /api/auth/me — Return current user profile
authRoutes.get("/me", async (c) => { authRoutes.get("/me", async (c) => {
try { try {
@@ -79,75 +90,3 @@ authRoutes.get("/me", async (c) => {
return c.json({ error: { code: "AUTH_ERROR", message: "Invalid or expired token" } }, 401); return c.json({ error: { code: "AUTH_ERROR", message: "Invalid or expired token" } }, 401);
} }
}); });
// POST /api/auth/passkey/register — Register a passkey
authRoutes.post("/passkey/register", async (c) => {
try {
const user = c.get("user");
if (!user) {
return c.json({ error: { code: "UNAUTHORIZED", message: "Not authenticated" } }, 401);
}
const body = await c.req.json();
const { credentialId, publicKey, counter } = body;
if (!credentialId || !publicKey) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "credentialId and publicKey are required" } }, 400);
}
await db
.update(users)
.set({
passkeyCredentialId: credentialId,
passkeyPublicKey: publicKey,
passkeyCounter: counter ?? 0,
})
.where(eq(users.id, user.id));
return c.json({ success: true });
} catch (error) {
console.error("[passkey/register] error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to register passkey" } }, 500);
}
});
// POST /api/auth/passkey/authenticate — Verify passkey login
authRoutes.post("/passkey/authenticate", async (c) => {
try {
const body = await c.req.json();
const { credentialId, signature, authenticatorData, clientDataJSON } = body;
if (!credentialId || !signature) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "credentialId and signature are required" } }, 400);
}
const [user] = await db
.select()
.from(users)
.where(eq(users.passkeyCredentialId, credentialId))
.limit(1);
if (!user) {
return c.json({ error: { code: "UNAUTHORIZED", message: "Passkey not found" } }, 401);
}
const token = await createToken({ id: user.id, email: user.email, name: user.name });
setCookie(c, "session", token, {
httpOnly: true,
secure: false,
sameSite: "Lax",
path: "/",
maxAge: 30 * 24 * 60 * 60,
});
return c.json({
user: {
id: user.id,
email: user.email,
name: user.name,
},
});
} catch (error) {
console.error("[passkey/authenticate] error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to verify passkey" } }, 500);
}
});
+9 -1
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { db, calendarEvents } from "@project-e/db"; import { db, calendarEvents } from "@project-e/db";
import { and, asc, desc, eq, gte, lte, isNull } from "drizzle-orm"; import { and, asc, desc, eq, gte, lte, isNull } from "drizzle-orm";
import { requireAuth, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth"; import { requireAuth, createErrorResponse, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth";
import { recordActivity } from "../middleware/activity"; import { recordActivity } from "../middleware/activity";
import { z } from "zod"; import { z } from "zod";
@@ -46,6 +46,7 @@ calendarRoutes.get("/events", async (c) => {
const active = await resolveActiveDomain(user); const active = await resolveActiveDomain(user);
domainId = active.id; domainId = active.id;
} }
await requireWorkspaceAccess(c, domainId);
const conditions: any[] = [eq(calendarEvents.domainId, domainId)]; const conditions: any[] = [eq(calendarEvents.domainId, domainId)];
if (from) conditions.push(gte(calendarEvents.startTime, new Date(from))); if (from) conditions.push(gte(calendarEvents.startTime, new Date(from)));
@@ -76,6 +77,8 @@ calendarRoutes.post("/events", async (c) => {
domain: body.domain || (await resolveActiveDomain(user)).id, domain: body.domain || (await resolveActiveDomain(user)).id,
}); });
await requireWorkspaceAccess(c, data.domain);
const [event] = await db.insert(calendarEvents).values({ const [event] = await db.insert(calendarEvents).values({
title: data.title, title: data.title,
description: data.description ?? null, description: data.description ?? null,
@@ -129,6 +132,8 @@ calendarRoutes.patch("/events/:id", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Event not found" } }, 404); return c.json({ error: { code: "NOT_FOUND", message: "Event not found" } }, 404);
} }
await requireWorkspaceAccess(c, existing.domainId);
const updateValues: Record<string, unknown> = {}; const updateValues: Record<string, unknown> = {};
if (data.title !== undefined) updateValues.title = data.title; if (data.title !== undefined) updateValues.title = data.title;
if (data.description !== undefined) updateValues.description = data.description; if (data.description !== undefined) updateValues.description = data.description;
@@ -184,6 +189,8 @@ calendarRoutes.delete("/events/:id", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Event not found" } }, 404); return c.json({ error: { code: "NOT_FOUND", message: "Event not found" } }, 404);
} }
await requireWorkspaceAccess(c, existing.domainId);
await db.delete(calendarEvents).where(eq(calendarEvents.id, id)); await db.delete(calendarEvents).where(eq(calendarEvents.id, id));
await recordActivity({ await recordActivity({
@@ -216,6 +223,7 @@ calendarRoutes.get("/upcoming", async (c) => {
const active = await resolveActiveDomain(user); const active = await resolveActiveDomain(user);
domainId = active.id; domainId = active.id;
} }
await requireWorkspaceAccess(c, domainId);
const now = new Date(); const now = new Date();
const end = new Date(); const end = new Date();
+215 -1
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { db, canvases, canvasCards, canvasConnections } from "@project-e/db"; import { db, canvases, canvasCards, canvasConnections } from "@project-e/db";
import { and, asc, desc, eq, sql } from "drizzle-orm"; import { and, asc, desc, eq, sql } from "drizzle-orm";
import { requireAuth, resolveActiveDomain, AuthError } from "../middleware/auth"; import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth";
import { recordActivity } from "../middleware/activity"; import { recordActivity } from "../middleware/activity";
import { z } from "zod"; import { z } from "zod";
@@ -28,6 +28,39 @@ const updateCanvasSchema = z.object({
customFields: z.record(z.string(), z.unknown()).optional(), customFields: z.record(z.string(), z.unknown()).optional(),
}); });
const createCardSchema = z.object({
type: z.string().min(1).default("note"),
content: z.string().optional().nullable().default(""),
title: z.string().optional().nullable(),
x: z.number().int().optional(),
y: z.number().int().optional(),
width: z.number().int().optional(),
height: z.number().int().optional(),
rotation: z.number().int().optional(),
color: z.string().optional().nullable(),
zIndex: z.number().int().optional(),
});
const updateCardSchema = createCardSchema.partial();
const bulkSaveCardsSchema = z.object({
cards: z.array(
z.object({
id: z.string().uuid().optional(),
type: z.string().min(1).default("note"),
content: z.string().optional().nullable().default(""),
title: z.string().optional().nullable(),
x: z.number().int().optional(),
y: z.number().int().optional(),
width: z.number().int().optional(),
height: z.number().int().optional(),
rotation: z.number().int().optional(),
color: z.string().optional().nullable(),
zIndex: z.number().int().optional(),
})
).default([]),
});
// GET /api/canvas — List canvases // GET /api/canvas — List canvases
canvasRoutes.get("/", async (c) => { canvasRoutes.get("/", async (c) => {
try { try {
@@ -41,6 +74,7 @@ canvasRoutes.get("/", async (c) => {
const active = await resolveActiveDomain(user); const active = await resolveActiveDomain(user);
domainId = active.id; domainId = active.id;
} }
await requireWorkspaceAccess(c, domainId);
const conditions: any[] = [eq(canvases.domainId, domainId)]; const conditions: any[] = [eq(canvases.domainId, domainId)];
const sortField = sort.replace(/^-/, ""); const sortField = sort.replace(/^-/, "");
@@ -71,6 +105,8 @@ canvasRoutes.post("/", async (c) => {
domain: body.domain || (await resolveActiveDomain(user)).id, domain: body.domain || (await resolveActiveDomain(user)).id,
}); });
await requireWorkspaceAccess(c, data.domain);
const [canvas] = await db.insert(canvases).values({ const [canvas] = await db.insert(canvases).values({
name: data.name, name: data.name,
description: data.description ?? null, description: data.description ?? null,
@@ -104,6 +140,8 @@ canvasRoutes.get("/:id", async (c) => {
const [canvas] = await db.select().from(canvases).where(eq(canvases.id, id)).limit(1); const [canvas] = await db.select().from(canvases).where(eq(canvases.id, id)).limit(1);
if (!canvas) return c.json({ error: { code: "NOT_FOUND", message: "Canvas not found" } }, 404); if (!canvas) return c.json({ error: { code: "NOT_FOUND", message: "Canvas not found" } }, 404);
await requireWorkspaceAccess(c, canvas.domainId);
const [cards, connections] = await Promise.all([ const [cards, connections] = await Promise.all([
db.select().from(canvasCards).where(eq(canvasCards.canvasId, id)).orderBy(asc(canvasCards.zIndex)), db.select().from(canvasCards).where(eq(canvasCards.canvasId, id)).orderBy(asc(canvasCards.zIndex)),
db.select().from(canvasConnections).where(eq(canvasConnections.canvasId, id)), db.select().from(canvasConnections).where(eq(canvasConnections.canvasId, id)),
@@ -128,6 +166,8 @@ canvasRoutes.patch("/:id", async (c) => {
const [existing] = await db.select().from(canvases).where(eq(canvases.id, id)).limit(1); const [existing] = await db.select().from(canvases).where(eq(canvases.id, id)).limit(1);
if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Canvas not found" } }, 404); if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Canvas not found" } }, 404);
await requireWorkspaceAccess(c, existing.domainId);
const updateValues: Record<string, unknown> = {}; const updateValues: Record<string, unknown> = {};
if (data.name !== undefined) updateValues.name = data.name; if (data.name !== undefined) updateValues.name = data.name;
if (data.description !== undefined) updateValues.description = data.description; if (data.description !== undefined) updateValues.description = data.description;
@@ -162,6 +202,8 @@ canvasRoutes.delete("/:id", async (c) => {
const [existing] = await db.select().from(canvases).where(eq(canvases.id, id)).limit(1); const [existing] = await db.select().from(canvases).where(eq(canvases.id, id)).limit(1);
if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Canvas not found" } }, 404); if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Canvas not found" } }, 404);
await requireWorkspaceAccess(c, existing.domainId);
await db.delete(canvases).where(eq(canvases.id, id)); await db.delete(canvases).where(eq(canvases.id, id));
await recordActivity({ await recordActivity({
@@ -176,3 +218,175 @@ canvasRoutes.delete("/:id", async (c) => {
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete canvas" } }, 500); return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete canvas" } }, 500);
} }
}); });
// POST /api/canvas/:id/cards — Create one card (new block)
canvasRoutes.post("/:id/cards", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
const body = await c.req.json();
const data = createCardSchema.parse(body);
const [canvas] = await db.select().from(canvases).where(eq(canvases.id, id)).limit(1);
if (!canvas) return c.json({ error: { code: "NOT_FOUND", message: "Canvas not found" } }, 404);
await requireWorkspaceAccess(c, canvas.domainId);
// New cards append to the end of the vertical document unless an explicit zIndex is given
const [maxRow] = await db
.select({ max: sql<number>`max(${canvasCards.zIndex})` })
.from(canvasCards)
.where(eq(canvasCards.canvasId, id));
const zIndex = data.zIndex ?? Number(maxRow?.max ?? -1) + 1;
const [card] = await db.insert(canvasCards).values({
canvasId: id,
type: data.type,
content: data.content ?? "",
title: data.title ?? null,
x: data.x ?? 0,
y: data.y ?? 0,
width: data.width ?? 200,
height: data.height ?? 150,
rotation: data.rotation ?? 0,
color: data.color ?? null,
zIndex,
}).returning();
await recordActivity({
actor: user.name, action: "created", entityType: "canvas_card", entityId: card.id,
changes: { type: card.type, zIndex: card.zIndex }, workspaceId: canvas.domainId,
});
return c.json(card, 201);
} catch (error) {
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
if (error instanceof z.ZodError) return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400);
console.error("[canvas] POST /:id/cards error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create canvas card" } }, 500);
}
});
// PUT /api/canvas/:id/cards — Bulk replace all cards (primary save path)
canvasRoutes.put("/:id/cards", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
const body = await c.req.json();
const data = bulkSaveCardsSchema.parse(body);
const [canvas] = await db.select().from(canvases).where(eq(canvases.id, id)).limit(1);
if (!canvas) return c.json({ error: { code: "NOT_FOUND", message: "Canvas not found" } }, 404);
await requireWorkspaceAccess(c, canvas.domainId);
const cards = await db.transaction(async (tx) => {
await tx.delete(canvasCards).where(eq(canvasCards.canvasId, id));
if (data.cards.length === 0) return [];
return tx.insert(canvasCards).values(
data.cards.map((card, i) => ({
...(card.id ? { id: card.id } : {}),
canvasId: id,
type: card.type,
content: card.content ?? "",
title: card.title ?? null,
x: card.x ?? 0,
y: card.y ?? 0,
width: card.width ?? 200,
height: card.height ?? 150,
rotation: card.rotation ?? 0,
color: card.color ?? null,
zIndex: card.zIndex ?? i,
}))
).returning();
});
await recordActivity({
actor: user.name, action: "updated", entityType: "canvas", entityId: id,
changes: { name: canvas.name, cardCount: cards.length }, workspaceId: canvas.domainId,
});
return c.json({ ...canvas, cards });
} catch (error) {
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
if (error instanceof z.ZodError) return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400);
console.error("[canvas] PUT /:id/cards error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to save canvas cards" } }, 500);
}
});
// PATCH /api/canvas/cards/:cardId — Update one card
canvasRoutes.patch("/cards/:cardId", async (c) => {
try {
const user = await requireAuth(c);
const cardId = c.req.param("cardId");
const body = await c.req.json();
const data = updateCardSchema.parse(body);
const [card] = await db.select().from(canvasCards).where(eq(canvasCards.id, cardId)).limit(1);
if (!card) return c.json({ error: { code: "NOT_FOUND", message: "Canvas card not found" } }, 404);
// canvas_cards has no domain_id — resolve ownership through the parent canvas
const [canvas] = await db.select().from(canvases).where(eq(canvases.id, card.canvasId)).limit(1);
if (canvas) {
await requireWorkspaceAccess(c, canvas.domainId);
}
const updateValues: Record<string, unknown> = {};
if (data.type !== undefined) updateValues.type = data.type;
if (data.content !== undefined) updateValues.content = data.content;
if (data.title !== undefined) updateValues.title = data.title;
if (data.x !== undefined) updateValues.x = data.x;
if (data.y !== undefined) updateValues.y = data.y;
if (data.width !== undefined) updateValues.width = data.width;
if (data.height !== undefined) updateValues.height = data.height;
if (data.rotation !== undefined) updateValues.rotation = data.rotation;
if (data.color !== undefined) updateValues.color = data.color;
if (data.zIndex !== undefined) updateValues.zIndex = data.zIndex;
updateValues.updatedAt = new Date();
const [updated] = await db.update(canvasCards).set(updateValues).where(eq(canvasCards.id, cardId)).returning();
await recordActivity({
actor: user.name, action: "updated", entityType: "canvas_card", entityId: cardId,
changes: { ...data }, workspaceId: canvas?.domainId ?? "",
});
return c.json(updated);
} catch (error) {
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
if (error instanceof z.ZodError) return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400);
console.error("[canvas] PATCH /cards/:cardId error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to update canvas card" } }, 500);
}
});
// DELETE /api/canvas/cards/:cardId — Delete one card
canvasRoutes.delete("/cards/:cardId", async (c) => {
try {
const user = await requireAuth(c);
const cardId = c.req.param("cardId");
const [card] = await db.select().from(canvasCards).where(eq(canvasCards.id, cardId)).limit(1);
if (!card) return c.json({ error: { code: "NOT_FOUND", message: "Canvas card not found" } }, 404);
// canvas_cards has no domain_id — resolve ownership through the parent canvas
const [canvas] = await db.select().from(canvases).where(eq(canvases.id, card.canvasId)).limit(1);
if (canvas) {
await requireWorkspaceAccess(c, canvas.domainId);
}
// Hard delete — canvas_cards has no deleted_at column
await db.delete(canvasCards).where(eq(canvasCards.id, cardId));
await recordActivity({
actor: user.name, action: "deleted", entityType: "canvas_card", entityId: cardId,
changes: { type: card.type }, workspaceId: canvas?.domainId ?? "",
});
return c.body(null, 204);
} catch (error) {
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
console.error("[canvas] DELETE /cards/:cardId error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete canvas card" } }, 500);
}
});
+8 -1
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { db, customFields } from "@project-e/db"; import { db, customFields } from "@project-e/db";
import { and, asc, eq } from "drizzle-orm"; import { and, asc, eq } from "drizzle-orm";
import { requireAuth, resolveActiveDomain, AuthError } from "../middleware/auth"; import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth";
import { recordActivity } from "../middleware/activity"; import { recordActivity } from "../middleware/activity";
import { z } from "zod"; import { z } from "zod";
@@ -37,6 +37,7 @@ customFieldRoutes.get("/", async (c) => {
const active = await resolveActiveDomain(user); const active = await resolveActiveDomain(user);
domainId = active.id; domainId = active.id;
} }
await requireWorkspaceAccess(c, domainId);
const conditions: any[] = [eq(customFields.domainId, domainId)]; const conditions: any[] = [eq(customFields.domainId, domainId)];
if (entityType) { if (entityType) {
@@ -66,6 +67,8 @@ customFieldRoutes.post("/", async (c) => {
domain: body.domain || (await resolveActiveDomain(user)).id, domain: body.domain || (await resolveActiveDomain(user)).id,
}); });
await requireWorkspaceAccess(c, data.domain);
const [field] = await db.insert(customFields).values({ const [field] = await db.insert(customFields).values({
name: data.name, name: data.name,
type: data.type, type: data.type,
@@ -102,6 +105,8 @@ customFieldRoutes.patch("/:id", async (c) => {
const [existing] = await db.select().from(customFields).where(eq(customFields.id, id)).limit(1); const [existing] = await db.select().from(customFields).where(eq(customFields.id, id)).limit(1);
if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Custom field not found" } }, 404); if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Custom field not found" } }, 404);
await requireWorkspaceAccess(c, existing.domainId);
const updateValues: Record<string, unknown> = {}; const updateValues: Record<string, unknown> = {};
if (data.name !== undefined) updateValues.name = data.name; if (data.name !== undefined) updateValues.name = data.name;
if (data.type !== undefined) updateValues.type = data.type; if (data.type !== undefined) updateValues.type = data.type;
@@ -135,6 +140,8 @@ customFieldRoutes.delete("/:id", async (c) => {
const [existing] = await db.select().from(customFields).where(eq(customFields.id, id)).limit(1); const [existing] = await db.select().from(customFields).where(eq(customFields.id, id)).limit(1);
if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Custom field not found" } }, 404); if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Custom field not found" } }, 404);
await requireWorkspaceAccess(c, existing.domainId);
await db.delete(customFields).where(eq(customFields.id, id)); await db.delete(customFields).where(eq(customFields.id, id));
await recordActivity({ await recordActivity({
+33 -1
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { db, dailyNotes } from "@project-e/db"; import { db, dailyNotes } from "@project-e/db";
import { and, desc, eq } from "drizzle-orm"; import { and, desc, eq } from "drizzle-orm";
import { requireAuth, resolveActiveDomain, AuthError } from "../middleware/auth"; import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth";
import { recordActivity } from "../middleware/activity"; import { recordActivity } from "../middleware/activity";
import { z } from "zod"; import { z } from "zod";
@@ -33,6 +33,7 @@ dailyNoteRoutes.get("/", async (c) => {
const active = await resolveActiveDomain(user); const active = await resolveActiveDomain(user);
domainId = active.id; domainId = active.id;
} }
await requireWorkspaceAccess(c, domainId);
if (dateStr) { if (dateStr) {
const startOfDay = new Date(dateStr + "T00:00:00.000Z"); const startOfDay = new Date(dateStr + "T00:00:00.000Z");
@@ -70,6 +71,8 @@ dailyNoteRoutes.post("/", async (c) => {
domain: body.domain || (await resolveActiveDomain(user)).id, domain: body.domain || (await resolveActiveDomain(user)).id,
}); });
await requireWorkspaceAccess(c, data.domain);
const [note] = await db.insert(dailyNotes).values({ const [note] = await db.insert(dailyNotes).values({
date: new Date(data.date + "T00:00:00.000Z"), date: new Date(data.date + "T00:00:00.000Z"),
content: data.content ?? null, content: data.content ?? null,
@@ -104,6 +107,8 @@ dailyNoteRoutes.patch("/:id", async (c) => {
const [existing] = await db.select().from(dailyNotes).where(eq(dailyNotes.id, id)).limit(1); const [existing] = await db.select().from(dailyNotes).where(eq(dailyNotes.id, id)).limit(1);
if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Daily note not found" } }, 404); if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Daily note not found" } }, 404);
await requireWorkspaceAccess(c, existing.domainId);
const updateValues: Record<string, unknown> = {}; const updateValues: Record<string, unknown> = {};
if (data.content !== undefined) updateValues.content = data.content; if (data.content !== undefined) updateValues.content = data.content;
if (data.mood !== undefined) updateValues.mood = data.mood; if (data.mood !== undefined) updateValues.mood = data.mood;
@@ -126,3 +131,30 @@ dailyNoteRoutes.patch("/:id", async (c) => {
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to update daily note" } }, 500); return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to update daily note" } }, 500);
} }
}); });
// DELETE /api/daily-notes/:id — Delete a note
dailyNoteRoutes.delete("/:id", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
const [existing] = await db.select().from(dailyNotes).where(eq(dailyNotes.id, id)).limit(1);
if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Daily note not found" } }, 404);
await requireWorkspaceAccess(c, existing.domainId);
// daily_notes has no deleted_at column, so this is a hard delete.
await db.delete(dailyNotes).where(eq(dailyNotes.id, id));
await recordActivity({
actor: user.name, action: "deleted", entityType: "daily_note", entityId: id,
changes: { date: existing.date.toISOString() }, workspaceId: existing.domainId,
});
return c.body(null, 204);
} catch (error) {
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
console.error("[daily-notes] DELETE error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete daily note" } }, 500);
}
});
+7 -1
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { db, dashboardWidgets } from "@project-e/db"; import { db, dashboardWidgets } from "@project-e/db";
import { and, asc, eq } from "drizzle-orm"; import { and, asc, eq } from "drizzle-orm";
import { requireAuth, resolveActiveDomain, AuthError } from "../middleware/auth"; import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth";
import { recordActivity } from "../middleware/activity"; import { recordActivity } from "../middleware/activity";
import { z } from "zod"; import { z } from "zod";
@@ -70,6 +70,8 @@ dashboardRoutes.post("/widgets", async (c) => {
domain: body.domain || (await resolveActiveDomain(user)).id, domain: body.domain || (await resolveActiveDomain(user)).id,
}); });
await requireWorkspaceAccess(c, data.domain!);
const [widget] = await db.insert(dashboardWidgets).values({ const [widget] = await db.insert(dashboardWidgets).values({
userId: user.id, userId: user.id,
type: data.type, type: data.type,
@@ -118,6 +120,8 @@ dashboardRoutes.patch("/widgets/:id", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Widget not found" } }, 404); return c.json({ error: { code: "NOT_FOUND", message: "Widget not found" } }, 404);
} }
await requireWorkspaceAccess(c, existing.domainId);
const updateValues: Record<string, unknown> = {}; const updateValues: Record<string, unknown> = {};
if (data.type !== undefined) updateValues.type = data.type; if (data.type !== undefined) updateValues.type = data.type;
if (data.title !== undefined) updateValues.title = data.title; if (data.title !== undefined) updateValues.title = data.title;
@@ -167,6 +171,8 @@ dashboardRoutes.delete("/widgets/:id", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Widget not found" } }, 404); return c.json({ error: { code: "NOT_FOUND", message: "Widget not found" } }, 404);
} }
await requireWorkspaceAccess(c, existing.domainId);
await db.delete(dashboardWidgets).where(eq(dashboardWidgets.id, id)); await db.delete(dashboardWidgets).where(eq(dashboardWidgets.id, id));
await recordActivity({ await recordActivity({
+63 -17
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { db, domains, notes, noteLinks, noteEntityLinks, tasks, taskDependencies, habits, projects, sections, tags as tagsTable } from "@project-e/db"; import { db, domains, notes, noteLinks, noteEntityLinks, tasks, taskDependencies, habits, projects, sections, tags as tagsTable } from "@project-e/db";
import { and, eq, inArray, isNull } from "drizzle-orm"; import { and, eq, inArray, isNull } from "drizzle-orm";
import { requireAuth, AuthError } from "../middleware/auth"; import { requireAuth, requireWorkspaceAccess, AuthError } from "../middleware/auth";
import { recordActivity } from "../middleware/activity"; import { recordActivity } from "../middleware/activity";
import { z } from "zod"; import { z } from "zod";
@@ -80,6 +80,20 @@ async function getGraphData(domainId: string): Promise<{ nodes: GraphNode[]; edg
return { nodes, edges }; return { nodes, edges };
} }
// Resolve the owning domain for a graph edge source. `type` may be an edge type
// (note_link / note_entity / task_dependency) or a source entity type (note / task).
async function resolveEdgeWorkspaceId(sourceId: string, type: string): Promise<string | null> {
if (type === "note_link" || type === "note_entity" || type === "note") {
const [row] = await db.select({ domainId: notes.domainId }).from(notes).where(eq(notes.id, sourceId)).limit(1);
return row?.domainId ?? null;
}
if (type === "task_dependency" || type === "task") {
const [row] = await db.select({ domainId: tasks.domainId }).from(tasks).where(eq(tasks.id, sourceId)).limit(1);
return row?.domainId ?? null;
}
return null;
}
// GET /api/graph/nodes — All nodes // GET /api/graph/nodes — All nodes
graphRoutes.get("/nodes", async (c) => { graphRoutes.get("/nodes", async (c) => {
try { try {
@@ -89,6 +103,7 @@ graphRoutes.get("/nodes", async (c) => {
if (!domainId) { if (!domainId) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "domain parameter is required" } }, 400); return c.json({ error: { code: "VALIDATION_ERROR", message: "domain parameter is required" } }, 400);
} }
await requireWorkspaceAccess(c, domainId);
const data = await getGraphData(domainId); const data = await getGraphData(domainId);
return c.json({ items: data.nodes, totalItems: data.nodes.length }); return c.json({ items: data.nodes, totalItems: data.nodes.length });
} catch (error) { } catch (error) {
@@ -109,6 +124,7 @@ graphRoutes.get("/edges", async (c) => {
if (!domainId) { if (!domainId) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "domain parameter is required" } }, 400); return c.json({ error: { code: "VALIDATION_ERROR", message: "domain parameter is required" } }, 400);
} }
await requireWorkspaceAccess(c, domainId);
const data = await getGraphData(domainId); const data = await getGraphData(domainId);
return c.json({ items: data.edges, totalItems: data.edges.length }); return c.json({ items: data.edges, totalItems: data.edges.length });
} catch (error) { } catch (error) {
@@ -131,6 +147,18 @@ graphRoutes.post("/edges", async (c) => {
type: z.string().default("note_link"), type: z.string().default("note_link"),
}).parse(body); }).parse(body);
// Verify ownership before mutating anything. Both endpoints of the edge
// must belong to the caller's domain.
const workspaceId = await resolveEdgeWorkspaceId(sourceId, type);
if (workspaceId) {
await requireWorkspaceAccess(c, workspaceId);
}
const targetType = type === "note_link" ? "note" : (type === "note_entity" || type === "task_dependency") ? "task" : type;
const targetWorkspaceId = await resolveEdgeWorkspaceId(targetId, targetType);
if (targetWorkspaceId) {
await requireWorkspaceAccess(c, targetWorkspaceId);
}
if (type === "note_link") { if (type === "note_link") {
await db.insert(noteLinks).values({ sourceNoteId: sourceId, targetNoteId: targetId }); await db.insert(noteLinks).values({ sourceNoteId: sourceId, targetNoteId: targetId });
} else if (type === "note_entity") { } else if (type === "note_entity") {
@@ -141,14 +169,18 @@ graphRoutes.post("/edges", async (c) => {
return c.json({ error: { code: "VALIDATION_ERROR", message: "Unknown edge type: " + type } }, 400); return c.json({ error: { code: "VALIDATION_ERROR", message: "Unknown edge type: " + type } }, 400);
} }
await recordActivity({ if (!workspaceId) {
actor: user.name, console.warn(`[graph] POST /edges: could not resolve workspace for source ${sourceId} (type ${type}); skipping activity`);
action: "created", } else {
entityType: "graph_edge", await recordActivity({
entityId: sourceId + "-" + targetId, actor: user.name,
changes: { type, sourceId, targetId }, action: "created",
workspaceId: "", entityType: "graph_edge",
}); entityId: sourceId + "-" + targetId,
changes: { type, sourceId, targetId },
workspaceId,
});
}
return c.json({ success: true }, 201); return c.json({ success: true }, 201);
} catch (error) { } catch (error) {
@@ -170,6 +202,16 @@ graphRoutes.delete("/edges/:id", async (c) => {
const id = c.req.param("id"); const id = c.req.param("id");
const [sourceId, targetId] = id.split("-"); const [sourceId, targetId] = id.split("-");
// The type isn't known at delete time, so resolve from the source entity:
// it's either a note or a task. Verify ownership before mutating anything.
let workspaceId = await resolveEdgeWorkspaceId(sourceId, "note");
if (!workspaceId) {
workspaceId = await resolveEdgeWorkspaceId(sourceId, "task");
}
if (workspaceId) {
await requireWorkspaceAccess(c, workspaceId);
}
// Try deleting from note_links first // Try deleting from note_links first
const result = await db.delete(noteLinks) const result = await db.delete(noteLinks)
.where(and(eq(noteLinks.sourceNoteId, sourceId), eq(noteLinks.targetNoteId, targetId))) .where(and(eq(noteLinks.sourceNoteId, sourceId), eq(noteLinks.targetNoteId, targetId)))
@@ -181,14 +223,18 @@ graphRoutes.delete("/edges/:id", async (c) => {
.where(and(eq(taskDependencies.taskId, sourceId), eq(taskDependencies.dependsOnTaskId, targetId))); .where(and(eq(taskDependencies.taskId, sourceId), eq(taskDependencies.dependsOnTaskId, targetId)));
} }
await recordActivity({ if (!workspaceId) {
actor: user.name, console.warn(`[graph] DELETE /edges/${id}: could not resolve workspace for source ${sourceId}; skipping activity`);
action: "deleted", } else {
entityType: "graph_edge", await recordActivity({
entityId: id, actor: user.name,
changes: {}, action: "deleted",
workspaceId: "", entityType: "graph_edge",
}); entityId: id,
changes: {},
workspaceId,
});
}
return c.body(null, 204); return c.body(null, 204);
} catch (error) { } catch (error) {
+129 -3
View File
@@ -1,8 +1,9 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { db, habits, habitCompletions, habitTags, tags as tagsTable } from "@project-e/db"; import { db, habits, habitCompletions, habitTags, tags as tagsTable } from "@project-e/db";
import { and, asc, desc, eq, gte, ilike, inArray, isNull, lte, sql } from "drizzle-orm"; import { and, asc, desc, eq, exists, gte, ilike, inArray, isNull, lte, sql } from "drizzle-orm";
import { requireAuth, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth"; import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth";
import { recordActivity } from "../middleware/activity"; import { recordActivity } from "../middleware/activity";
import { enqueueWebhooks } from "../middleware/webhook-queue";
import { z } from "zod"; import { z } from "zod";
export const habitRoutes = new Hono(); export const habitRoutes = new Hono();
@@ -96,6 +97,7 @@ habitRoutes.get("/", async (c) => {
const active = url.searchParams.get("active"); const active = url.searchParams.get("active");
const frequency = url.searchParams.get("frequency"); const frequency = url.searchParams.get("frequency");
const difficulty = url.searchParams.get("difficulty"); const difficulty = url.searchParams.get("difficulty");
const tag = url.searchParams.get("tag");
const search = url.searchParams.get("search"); const search = url.searchParams.get("search");
const limit = Math.min(parseInt(url.searchParams.get("limit") || "50"), 200); const limit = Math.min(parseInt(url.searchParams.get("limit") || "50"), 200);
const offset = parseInt(url.searchParams.get("offset") || "0"); const offset = parseInt(url.searchParams.get("offset") || "0");
@@ -107,6 +109,8 @@ habitRoutes.get("/", async (c) => {
domainId = active.id; domainId = active.id;
} }
await requireWorkspaceAccess(c, domainId);
const conditions: any[] = [ const conditions: any[] = [
eq(habits.domainId, domainId), eq(habits.domainId, domainId),
isNull(habits.deletedAt), isNull(habits.deletedAt),
@@ -118,6 +122,20 @@ habitRoutes.get("/", async (c) => {
if (difficulty) conditions.push(eq(habits.difficulty, difficulty as any)); if (difficulty) conditions.push(eq(habits.difficulty, difficulty as any));
if (search) conditions.push(ilike(habits.name, `%${search}%`)); if (search) conditions.push(ilike(habits.name, `%${search}%`));
if (filter) conditions.push(ilike(habits.name, `%${filter}%`)); if (filter) conditions.push(ilike(habits.name, `%${filter}%`));
// Tag filter applied in SQL (EXISTS on the junction table) so it runs over
// the full dataset before pagination.
if (tag) {
const tagIds = tag.split(",").map((t) => t.trim()).filter(Boolean);
if (tagIds.length > 0) {
conditions.push(
exists(
db.select({ one: sql`1` })
.from(habitTags)
.where(and(eq(habitTags.habitId, habits.id), inArray(habitTags.tagId, tagIds)))
)
);
}
}
const sortDir = sort.startsWith("-") ? "desc" : "asc"; const sortDir = sort.startsWith("-") ? "desc" : "asc";
const sortField = sort.replace(/^-/, ""); const sortField = sort.replace(/^-/, "");
@@ -202,6 +220,8 @@ habitRoutes.post("/", async (c) => {
domain: body.domain || (await resolveActiveDomain(user)).id, domain: body.domain || (await resolveActiveDomain(user)).id,
}); });
await requireWorkspaceAccess(c, data.domain);
const [habit] = await db.insert(habits).values({ const [habit] = await db.insert(habits).values({
name: data.name, name: data.name,
description: data.description ?? null, description: data.description ?? null,
@@ -231,6 +251,8 @@ habitRoutes.post("/", async (c) => {
workspaceId: data.domain, workspaceId: data.domain,
}); });
await enqueueWebhooks({ workspaceId: data.domain, event: "habit.created", entityType: "habit", entityId: habit.id, data: { name: habit.name } });
return c.json(habit, 201); return c.json(habit, 201);
} catch (error) { } catch (error) {
if (error instanceof AuthError) { if (error instanceof AuthError) {
@@ -259,6 +281,8 @@ habitRoutes.get("/:id", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Habit not found" } }, 404); return c.json({ error: { code: "NOT_FOUND", message: "Habit not found" } }, 404);
} }
await requireWorkspaceAccess(c, habit.domainId);
// Fetch recent completions (last 30 days) // Fetch recent completions (last 30 days)
const thirtyDaysAgo = new Date(); const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
@@ -312,6 +336,8 @@ habitRoutes.patch("/:id", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Habit not found" } }, 404); return c.json({ error: { code: "NOT_FOUND", message: "Habit not found" } }, 404);
} }
await requireWorkspaceAccess(c, existing.domainId);
const updateValues: Record<string, unknown> = {}; const updateValues: Record<string, unknown> = {};
if (data.name !== undefined) updateValues.name = data.name; if (data.name !== undefined) updateValues.name = data.name;
if (data.description !== undefined) updateValues.description = data.description; if (data.description !== undefined) updateValues.description = data.description;
@@ -339,6 +365,8 @@ habitRoutes.patch("/:id", async (c) => {
workspaceId: existing.domainId, workspaceId: existing.domainId,
}); });
await enqueueWebhooks({ workspaceId: existing.domainId, event: "habit.updated", entityType: "habit", entityId: id, data: { ...data, previousName: existing.name } });
return c.json(updated); return c.json(updated);
} catch (error) { } catch (error) {
if (error instanceof AuthError) { if (error instanceof AuthError) {
@@ -367,6 +395,8 @@ habitRoutes.delete("/:id", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Habit not found" } }, 404); return c.json({ error: { code: "NOT_FOUND", message: "Habit not found" } }, 404);
} }
await requireWorkspaceAccess(c, existing.domainId);
await db.update(habits) await db.update(habits)
.set({ deletedAt: new Date(), updatedAt: new Date() }) .set({ deletedAt: new Date(), updatedAt: new Date() })
.where(eq(habits.id, id)); .where(eq(habits.id, id));
@@ -380,6 +410,8 @@ habitRoutes.delete("/:id", async (c) => {
workspaceId: existing.domainId, workspaceId: existing.domainId,
}); });
await enqueueWebhooks({ workspaceId: existing.domainId, event: "habit.deleted", entityType: "habit", entityId: id, data: { name: existing.name } });
return c.body(null, 204); return c.body(null, 204);
} catch (error) { } catch (error) {
if (error instanceof AuthError) { if (error instanceof AuthError) {
@@ -390,6 +422,96 @@ habitRoutes.delete("/:id", async (c) => {
} }
}); });
// POST /api/habits/:id/tags — Assign a tag to a habit
habitRoutes.post("/:id/tags", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
const body = await c.req.json();
const { tagId } = z.object({ tagId: z.string().uuid("Invalid tag id") }).parse(body);
const [habit] = await db.select({ id: habits.id, domainId: habits.domainId })
.from(habits)
.where(and(eq(habits.id, id), isNull(habits.deletedAt)))
.limit(1);
if (!habit) {
return c.json({ error: { code: "NOT_FOUND", message: "Habit not found" } }, 404);
}
await requireWorkspaceAccess(c, habit.domainId);
const [tag] = await db.select({ id: tagsTable.id, name: tagsTable.name })
.from(tagsTable)
.where(eq(tagsTable.id, tagId))
.limit(1);
if (!tag) {
return c.json({ error: { code: "NOT_FOUND", message: "Tag not found" } }, 404);
}
// Junction table has a composite PK — ignore re-assigns instead of erroring
await db.insert(habitTags).values({ habitId: id, tagId }).onConflictDoNothing();
await recordActivity({
actor: user.name,
action: "tagged",
entityType: "habit",
entityId: id,
changes: { tagId, tagName: tag.name },
workspaceId: habit.domainId,
});
return c.json({ success: true }, 201);
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
if (error instanceof z.ZodError) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400);
}
console.error("[habits] POST /:id/tags error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to assign tag" } }, 500);
}
});
// DELETE /api/habits/:id/tags/:tagId — Remove a tag from a habit
habitRoutes.delete("/:id/tags/:tagId", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
const tagId = c.req.param("tagId");
const [habit] = await db.select({ id: habits.id, domainId: habits.domainId })
.from(habits)
.where(and(eq(habits.id, id), isNull(habits.deletedAt)))
.limit(1);
if (!habit) {
return c.json({ error: { code: "NOT_FOUND", message: "Habit not found" } }, 404);
}
await requireWorkspaceAccess(c, habit.domainId);
// Junction tables have no deleted_at — hard delete is correct here
await db.delete(habitTags).where(and(eq(habitTags.habitId, id), eq(habitTags.tagId, tagId)));
await recordActivity({
actor: user.name,
action: "untagged",
entityType: "habit",
entityId: id,
changes: { tagId },
workspaceId: habit.domainId,
});
return c.body(null, 204);
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[habits] DELETE /:id/tags/:tagId error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to remove tag" } }, 500);
}
});
// POST /api/habits/:id/complete — Complete a habit for today // POST /api/habits/:id/complete — Complete a habit for today
habitRoutes.post("/:id/complete", async (c) => { habitRoutes.post("/:id/complete", async (c) => {
try { try {
@@ -407,6 +529,8 @@ habitRoutes.post("/:id/complete", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Habit not found" } }, 404); return c.json({ error: { code: "NOT_FOUND", message: "Habit not found" } }, 404);
} }
await requireWorkspaceAccess(c, habit.domainId);
const [completion] = await db.insert(habitCompletions).values({ const [completion] = await db.insert(habitCompletions).values({
habitId: id, habitId: id,
date: new Date(), date: new Date(),
@@ -465,7 +589,7 @@ habitRoutes.get("/:id/completions", async (c) => {
const id = c.req.param("id"); const id = c.req.param("id");
const url = new URL(c.req.url); const url = new URL(c.req.url);
const [habit] = await db.select({ id: habits.id }) const [habit] = await db.select({ id: habits.id, domainId: habits.domainId })
.from(habits) .from(habits)
.where(and(eq(habits.id, id), isNull(habits.deletedAt))) .where(and(eq(habits.id, id), isNull(habits.deletedAt)))
.limit(1); .limit(1);
@@ -474,6 +598,8 @@ habitRoutes.get("/:id/completions", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Habit not found" } }, 404); return c.json({ error: { code: "NOT_FOUND", message: "Habit not found" } }, 404);
} }
await requireWorkspaceAccess(c, habit.domainId);
const from = url.searchParams.get("from"); const from = url.searchParams.get("from");
const to = url.searchParams.get("to"); const to = url.searchParams.get("to");
const limit = Math.min(parseInt(url.searchParams.get("limit") || "365"), 1000); const limit = Math.min(parseInt(url.searchParams.get("limit") || "365"), 1000);
+48 -17
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { db, tasks, habits, projects, notes, tags as tagsTable, agents, webhooks } from "@project-e/db"; import { db, tasks, habits, projects, notes, tags as tagsTable, agents, webhooks, taskTags, habitTags, projectTags, noteTags } from "@project-e/db";
import { eq, isNull } from "drizzle-orm"; import { and, eq, inArray, isNull } from "drizzle-orm";
import { requireAuth, createErrorResponse, AuthError } from "../middleware/auth"; import { requireAuth, requireWorkspaceAccess, resolveActiveDomain, createErrorResponse, AuthError } from "../middleware/auth";
import { z } from "zod"; import { z } from "zod";
export const importExportRoutes = new Hono(); export const importExportRoutes = new Hono();
@@ -22,6 +22,11 @@ importExportRoutes.post("/import", async (c) => {
return c.json({ error: { code: "INVALID_DATA", message: "Missing version field" } }, 400); return c.json({ error: { code: "INVALID_DATA", message: "Missing version field" } }, 400);
} }
// Every imported entity is forced into a single target domain that the
// current user owns. Payload-supplied domain ids are never trusted.
const targetDomain = body.domain || body.domain_id || (await resolveActiveDomain(user)).id;
await requireWorkspaceAccess(c, targetDomain);
const results: Array<{ collection: string; imported: number; failed: number; errors: string[] }> = []; const results: Array<{ collection: string; imported: number; failed: number; errors: string[] }> = [];
let totalImported = 0; let totalImported = 0;
let totalFailed = 0; let totalFailed = 0;
@@ -38,25 +43,25 @@ importExportRoutes.post("/import", async (c) => {
// Map to the right table // Map to the right table
switch (collection) { switch (collection) {
case 'tasks': case 'tasks':
await db.insert(tasks).values({ ...data, domainId: data.domain_id || data.domainId }); await db.insert(tasks).values({ ...data, domainId: targetDomain });
break; break;
case 'habits': case 'habits':
await db.insert(habits).values({ ...data, domainId: data.domain_id || data.domainId }); await db.insert(habits).values({ ...data, domainId: targetDomain });
break; break;
case 'projects': case 'projects':
await db.insert(projects).values({ ...data, domainId: data.domain_id || data.domainId }); await db.insert(projects).values({ ...data, domainId: targetDomain });
break; break;
case 'notes': case 'notes':
await db.insert(notes).values({ ...data, domainId: data.domain_id || data.domainId }); await db.insert(notes).values({ ...data, domainId: targetDomain });
break; break;
case 'tags': case 'tags':
await db.insert(tagsTable).values(data); await db.insert(tagsTable).values(data);
break; break;
case 'agents': case 'agents':
await db.insert(agents).values({ ...data, domainId: data.domain_id || data.domainId }); await db.insert(agents).values({ ...data, domainId: targetDomain });
break; break;
case 'webhooks': case 'webhooks':
await db.insert(webhooks).values({ ...data, workspaceId: data.workspace_id || data.workspaceId || data.domain_id || data.domainId }); await db.insert(webhooks).values({ ...data, workspaceId: targetDomain });
break; break;
} }
result.imported++; result.imported++;
@@ -101,9 +106,15 @@ importExportRoutes.get("/export", async (c) => {
importExportRoutes.post("/export", async (c) => { importExportRoutes.post("/export", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
let body: { collections?: string[] } = {}; let body: { collections?: string[]; domain?: string } = {};
try { body = await c.req.json(); } catch { /* empty body is fine */ } try { body = await c.req.json(); } catch { /* empty body is fine */ }
// Scope the entire export to one domain owned by the current user.
// An optional `domain` in the body can override the active domain, but it
// must still pass the ownership check.
const domainId = body.domain || (await resolveActiveDomain(user)).id;
await requireWorkspaceAccess(c, domainId);
const requestedCollections = body.collections && body.collections.length > 0 const requestedCollections = body.collections && body.collections.length > 0
? body.collections.filter(c => COLLECTIONS.includes(c as typeof COLLECTIONS[number])) ? body.collections.filter(c => COLLECTIONS.includes(c as typeof COLLECTIONS[number]))
: [...COLLECTIONS]; : [...COLLECTIONS];
@@ -117,13 +128,33 @@ importExportRoutes.post("/export", async (c) => {
try { try {
let items: any[] = []; let items: any[] = [];
switch (collection) { switch (collection) {
case 'tasks': items = await db.select().from(tasks).where(isNull(tasks.deletedAt)); break; case 'tasks': items = await db.select().from(tasks).where(and(eq(tasks.domainId, domainId), isNull(tasks.deletedAt))); break;
case 'habits': items = await db.select().from(habits).where(isNull(habits.deletedAt)); break; case 'habits': items = await db.select().from(habits).where(and(eq(habits.domainId, domainId), isNull(habits.deletedAt))); break;
case 'projects': items = await db.select().from(projects).where(isNull(projects.deletedAt)); break; case 'projects': items = await db.select().from(projects).where(and(eq(projects.domainId, domainId), isNull(projects.deletedAt))); break;
case 'notes': items = await db.select().from(notes).where(isNull(notes.deletedAt)); break; case 'notes': items = await db.select().from(notes).where(and(eq(notes.domainId, domainId), isNull(notes.deletedAt))); break;
case 'tags': items = await db.select().from(tagsTable); break; case 'tags': {
case 'agents': items = await db.select().from(agents); break; // Tags are global (no domain_id); export only tags actually used by
case 'webhooks': items = await db.select().from(webhooks); break; // this domain's entities via the four junction tables.
const [taskTagIds, habitTagIds, projectTagIds, noteTagIds] = await Promise.all([
db.select({ tagId: taskTags.tagId }).from(taskTags)
.innerJoin(tasks, eq(taskTags.taskId, tasks.id))
.where(and(eq(tasks.domainId, domainId), isNull(tasks.deletedAt))),
db.select({ tagId: habitTags.tagId }).from(habitTags)
.innerJoin(habits, eq(habitTags.habitId, habits.id))
.where(and(eq(habits.domainId, domainId), isNull(habits.deletedAt))),
db.select({ tagId: projectTags.tagId }).from(projectTags)
.innerJoin(projects, eq(projectTags.projectId, projects.id))
.where(and(eq(projects.domainId, domainId), isNull(projects.deletedAt))),
db.select({ tagId: noteTags.tagId }).from(noteTags)
.innerJoin(notes, eq(noteTags.noteId, notes.id))
.where(and(eq(notes.domainId, domainId), isNull(notes.deletedAt))),
]);
const tagIds = [...new Set([...taskTagIds, ...habitTagIds, ...projectTagIds, ...noteTagIds].map(r => r.tagId))];
items = tagIds.length > 0 ? await db.select().from(tagsTable).where(inArray(tagsTable.id, tagIds)) : [];
break;
}
case 'agents': items = await db.select().from(agents).where(eq(agents.domainId, domainId)); break;
case 'webhooks': items = await db.select().from(webhooks).where(eq(webhooks.workspaceId, domainId)); break;
} }
exportData[collection] = items; exportData[collection] = items;
} catch (error) { } catch (error) {
+67 -14
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { db, apiKeys, users, tasks, taskTags, tags as tagsTable, habits, habitCompletions, projects, notes, noteLinks, domains, activityFeed, webhooks, webhookDeliveries } from "@project-e/db"; import { db, apiKeys, users, tasks, taskTags, tags as tagsTable, habits, habitCompletions, projects, notes, noteLinks, domains, activityFeed, webhooks, webhookDeliveries } from "@project-e/db";
import { and, asc, desc, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm"; import { and, asc, desc, eq, ilike, isNull, or } from "drizzle-orm";
import { recordActivity } from "../middleware/activity"; import { recordActivity } from "../middleware/activity";
export const mcpRoutes = new Hono(); export const mcpRoutes = new Hono();
@@ -484,7 +484,7 @@ const tools: ToolDefinition[] = [
.where(and( .where(and(
eq(notes.domainId, params.domain_id as string), eq(notes.domainId, params.domain_id as string),
isNull(notes.deletedAt), isNull(notes.deletedAt),
or(ilike(notes.title, `%${query}%`), ilike(notes.content ?? sql``, `%${query}%`)) or(ilike(notes.title, `%${query}%`), ilike(notes.content, `%${query}%`))
)) ))
.orderBy(desc(notes.updatedAt)) .orderBy(desc(notes.updatedAt))
.limit(20); .limit(20);
@@ -493,10 +493,15 @@ const tools: ToolDefinition[] = [
}, },
{ {
name: "domains.list", name: "domains.list",
description: "List domains/workspaces", description: "List the caller's domains/workspaces",
inputSchema: { type: "object", properties: {} }, inputSchema: { type: "object", properties: {} },
handler: async () => { handler: async (_params, auth) => {
const items = await db.select().from(domains).orderBy(asc(domains.name)); // Only the caller's own domains (plus legacy ownerless rows) — never every
// domain in the database.
const items = await db.select()
.from(domains)
.where(or(eq(domains.ownerId, auth.userId), isNull(domains.ownerId)))
.orderBy(asc(domains.name));
return { items }; return { items };
}, },
}, },
@@ -517,6 +522,7 @@ const tools: ToolDefinition[] = [
name: params.name as string, name: params.name as string,
slug: params.slug as string, slug: params.slug as string,
color: (params.color as string) ?? null, color: (params.color as string) ?? null,
ownerId: auth.userId,
}).returning(); }).returning();
await recordActivity({ await recordActivity({
@@ -603,14 +609,48 @@ class JsonRpcErrorResponse extends Error {
} }
} }
function makeError(code: number, message: string, data?: unknown): JsonRpcResponse { function makeError(code: number, message: string, data?: unknown, id: string | number | null = null): JsonRpcResponse {
return { jsonrpc: "2.0", error: { code, message, data }, id: null }; return { jsonrpc: "2.0", error: { code, message, data }, id };
} }
function makeResult(result: unknown, id: string | number | null): JsonRpcResponse { function makeResult(result: unknown, id: string | number | null): JsonRpcResponse {
return { jsonrpc: "2.0", result, id }; return { jsonrpc: "2.0", result, id };
} }
// Validate that every field listed in the tool's inputSchema `required` array is
// present. Prevents silent empty-result queries (e.g. a missing domain_id) from
// reaching the database.
function validateParams(tool: ToolDefinition, args: Record<string, unknown>): string | null {
const schema = tool.inputSchema as { required?: string[] } | undefined;
for (const field of schema?.required || []) {
const value = args[field];
if (value === undefined || value === null || value === "") {
return `Missing required parameter: ${field}`;
}
}
return null;
}
// Domain/workspace ownership check for tools that receive a domain_id or
// workspace_id. Mirrors requireWorkspaceAccess but works without a Hono context:
// legacy domains with a NULL ownerId pass through the existence check only.
async function verifyDomainAccess(domainId: string, userId: string): Promise<void> {
if (!domainId || domainId.trim() === "") {
throw new JsonRpcErrorResponse(JSONRPC_INVALID_PARAMS, "domain_id is required");
}
const [domain] = await db
.select({ id: domains.id, ownerId: domains.ownerId })
.from(domains)
.where(eq(domains.id, domainId))
.limit(1);
if (!domain) {
throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, `Domain not found: ${domainId}`);
}
if (domain.ownerId !== null && domain.ownerId !== userId) {
throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, `No access to domain: ${domainId}`);
}
}
async function handleRequest(body: JsonRpcRequest, auth: { userId: string; userName: string }): Promise<JsonRpcResponse> { async function handleRequest(body: JsonRpcRequest, auth: { userId: string; userName: string }): Promise<JsonRpcResponse> {
const { method, params, id } = body; const { method, params, id } = body;
@@ -644,23 +684,36 @@ async function handleRequest(body: JsonRpcRequest, auth: { userId: string; userN
if (method === "tools/call") { if (method === "tools/call") {
const callParams = params as { name?: string; arguments?: Record<string, unknown> } | undefined; const callParams = params as { name?: string; arguments?: Record<string, unknown> } | undefined;
if (!callParams?.name) { if (!callParams?.name) {
return makeError(JSONRPC_INVALID_PARAMS, "Missing tool name", id); return makeError(JSONRPC_INVALID_PARAMS, "Missing tool name", undefined, id);
} }
const tool = tools.find(t => t.name === callParams.name); const tool = tools.find(t => t.name === callParams.name);
if (!tool) { if (!tool) {
return makeError(JSONRPC_METHOD_NOT_FOUND, `Unknown tool: ${callParams.name}`, id); return makeError(JSONRPC_METHOD_NOT_FOUND, `Unknown tool: ${callParams.name}`, undefined, id);
} }
try { try {
const result = await tool.handler(callParams.arguments || {}, auth); const args = callParams.arguments || {};
const missing = validateParams(tool, args);
if (missing) {
return makeError(JSONRPC_INVALID_PARAMS, missing, undefined, id);
}
// Scope to the caller's domains when a domain/workspace is passed.
const domainId = (args.domain_id as string | undefined) ?? (args.workspace_id as string | undefined);
if (domainId) {
await verifyDomainAccess(domainId, auth.userId);
}
const result = await tool.handler(args, auth);
return makeResult({ content: [{ type: "text", text: JSON.stringify(result) }] }, id); return makeResult({ content: [{ type: "text", text: JSON.stringify(result) }] }, id);
} catch (error) { } catch (error) {
if (error instanceof JsonRpcErrorResponse) { if (error instanceof JsonRpcErrorResponse) {
return makeError(error.code, error.message, error.data); return makeError(error.code, error.message, error.data, id);
} }
console.error(`[MCP] Tool ${callParams.name} error:`, error); console.error(`[MCP] Tool ${callParams.name} error:`, error);
return makeError(JSONRPC_INTERNAL_ERROR, error instanceof Error ? error.message : "Internal error", id); return makeError(JSONRPC_INTERNAL_ERROR, error instanceof Error ? error.message : "Internal error", undefined, id);
} }
} }
@@ -700,7 +753,7 @@ async function handleRequest(body: JsonRpcRequest, auth: { userId: string; userN
if (method === "resources/read") { if (method === "resources/read") {
const readParams = params as { uri?: string } | undefined; const readParams = params as { uri?: string } | undefined;
if (!readParams?.uri) { if (!readParams?.uri) {
return makeError(JSONRPC_INVALID_PARAMS, "Missing resource URI", id); return makeError(JSONRPC_INVALID_PARAMS, "Missing resource URI", undefined, id);
} }
return makeResult({ return makeResult({
contents: [ contents: [
@@ -727,7 +780,7 @@ async function handleRequest(body: JsonRpcRequest, auth: { userId: string; userN
}, id); }, id);
} }
return makeError(JSONRPC_METHOD_NOT_FOUND, `Unknown method: ${method}`, id); return makeError(JSONRPC_METHOD_NOT_FOUND, `Unknown method: ${method}`, undefined, id);
} }
// ── Route handler ──────────────────────────────────────────────────────────────── // ── Route handler ────────────────────────────────────────────────────────────────
+147 -2
View File
@@ -1,8 +1,9 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { db, notes, noteTags, tags as tagsTable, activityFeed } from "@project-e/db"; import { db, notes, noteTags, tags as tagsTable, activityFeed } from "@project-e/db";
import { and, asc, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm"; import { and, asc, desc, eq, exists, ilike, inArray, isNull, sql } from "drizzle-orm";
import { requireAuth, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth"; import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth";
import { recordActivity } from "../middleware/activity"; import { recordActivity } from "../middleware/activity";
import { enqueueWebhooks } from "../middleware/webhook-queue";
import { syncNoteLinks, getBacklinks, getOutgoingLinks } from "./note-link-service"; import { syncNoteLinks, getBacklinks, getOutgoingLinks } from "./note-link-service";
import { z } from "zod"; import { z } from "zod";
@@ -35,6 +36,7 @@ noteRoutes.get("/", async (c) => {
const sort = url.searchParams.get("sort") || "-updated_at"; const sort = url.searchParams.get("sort") || "-updated_at";
const pinned = url.searchParams.get("pinned"); const pinned = url.searchParams.get("pinned");
const archived = url.searchParams.get("archived"); const archived = url.searchParams.get("archived");
const tag = url.searchParams.get("tag");
const search = url.searchParams.get("search"); const search = url.searchParams.get("search");
const limit = Math.min(parseInt(url.searchParams.get("limit") || "50"), 200); const limit = Math.min(parseInt(url.searchParams.get("limit") || "50"), 200);
const offset = parseInt(url.searchParams.get("offset") || "0"); const offset = parseInt(url.searchParams.get("offset") || "0");
@@ -46,6 +48,8 @@ noteRoutes.get("/", async (c) => {
domainId = active.id; domainId = active.id;
} }
await requireWorkspaceAccess(c, domainId);
const conditions: any[] = [ const conditions: any[] = [
eq(notes.domainId, domainId), eq(notes.domainId, domainId),
isNull(notes.deletedAt), isNull(notes.deletedAt),
@@ -56,6 +60,20 @@ noteRoutes.get("/", async (c) => {
else if (archived !== "all") conditions.push(eq(notes.isArchived, false)); else if (archived !== "all") conditions.push(eq(notes.isArchived, false));
if (search) conditions.push(ilike(notes.title, `%${search}%`)); if (search) conditions.push(ilike(notes.title, `%${search}%`));
if (filter) conditions.push(ilike(notes.title, `%${filter}%`)); if (filter) conditions.push(ilike(notes.title, `%${filter}%`));
// Tag filter applied in SQL (EXISTS on the junction table) so it runs over
// the full dataset before pagination.
if (tag) {
const tagIds = tag.split(",").map((t) => t.trim()).filter(Boolean);
if (tagIds.length > 0) {
conditions.push(
exists(
db.select({ one: sql`1` })
.from(noteTags)
.where(and(eq(noteTags.noteId, notes.id), inArray(noteTags.tagId, tagIds)))
)
);
}
}
const sortDir = sort.startsWith("-") ? "desc" : "asc"; const sortDir = sort.startsWith("-") ? "desc" : "asc";
const sortField = sort.replace(/^-/, ""); const sortField = sort.replace(/^-/, "");
@@ -136,6 +154,8 @@ noteRoutes.post("/", async (c) => {
domain: body.domain || (await resolveActiveDomain(user)).id, domain: body.domain || (await resolveActiveDomain(user)).id,
}); });
await requireWorkspaceAccess(c, data.domain);
const [note] = await db.insert(notes).values({ const [note] = await db.insert(notes).values({
title: data.title, title: data.title,
content: data.content ?? null, content: data.content ?? null,
@@ -164,6 +184,8 @@ noteRoutes.post("/", async (c) => {
workspaceId: data.domain, workspaceId: data.domain,
}); });
await enqueueWebhooks({ workspaceId: data.domain, event: "note.created", entityType: "note", entityId: note.id, data: { title: note.title } });
return c.json(note, 201); return c.json(note, 201);
} catch (error) { } catch (error) {
if (error instanceof AuthError) { if (error instanceof AuthError) {
@@ -192,6 +214,8 @@ noteRoutes.get("/:id", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Note not found" } }, 404); return c.json({ error: { code: "NOT_FOUND", message: "Note not found" } }, 404);
} }
await requireWorkspaceAccess(c, note.domainId);
// Fetch tags // Fetch tags
const tagRows = await db.select({ const tagRows = await db.select({
id: tagsTable.id, id: tagsTable.id,
@@ -240,6 +264,8 @@ noteRoutes.patch("/:id", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Note not found" } }, 404); return c.json({ error: { code: "NOT_FOUND", message: "Note not found" } }, 404);
} }
await requireWorkspaceAccess(c, existing.domainId);
const updateValues: Record<string, unknown> = {}; const updateValues: Record<string, unknown> = {};
if (data.title !== undefined) updateValues.title = data.title; if (data.title !== undefined) updateValues.title = data.title;
if (data.content !== undefined) updateValues.content = data.content; if (data.content !== undefined) updateValues.content = data.content;
@@ -267,6 +293,8 @@ noteRoutes.patch("/:id", async (c) => {
workspaceId: existing.domainId, workspaceId: existing.domainId,
}); });
await enqueueWebhooks({ workspaceId: existing.domainId, event: "note.updated", entityType: "note", entityId: id, data: { ...data, previousTitle: existing.title } });
return c.json(updated); return c.json(updated);
} catch (error) { } catch (error) {
if (error instanceof AuthError) { if (error instanceof AuthError) {
@@ -295,6 +323,8 @@ noteRoutes.delete("/:id", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Note not found" } }, 404); return c.json({ error: { code: "NOT_FOUND", message: "Note not found" } }, 404);
} }
await requireWorkspaceAccess(c, existing.domainId);
await db.update(notes) await db.update(notes)
.set({ deletedAt: new Date(), updatedAt: new Date() }) .set({ deletedAt: new Date(), updatedAt: new Date() })
.where(eq(notes.id, id)); .where(eq(notes.id, id));
@@ -308,6 +338,8 @@ noteRoutes.delete("/:id", async (c) => {
workspaceId: existing.domainId, workspaceId: existing.domainId,
}); });
await enqueueWebhooks({ workspaceId: existing.domainId, event: "note.deleted", entityType: "note", entityId: id, data: { title: existing.title } });
return c.body(null, 204); return c.body(null, 204);
} catch (error) { } catch (error) {
if (error instanceof AuthError) { if (error instanceof AuthError) {
@@ -318,12 +350,113 @@ noteRoutes.delete("/:id", async (c) => {
} }
}); });
// POST /api/notes/:id/tags — Assign a tag to a note
noteRoutes.post("/:id/tags", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
const body = await c.req.json();
const { tagId } = z.object({ tagId: z.string().uuid("Invalid tag id") }).parse(body);
const [note] = await db.select({ id: notes.id, domainId: notes.domainId })
.from(notes)
.where(and(eq(notes.id, id), isNull(notes.deletedAt)))
.limit(1);
if (!note) {
return c.json({ error: { code: "NOT_FOUND", message: "Note not found" } }, 404);
}
await requireWorkspaceAccess(c, note.domainId);
const [tag] = await db.select({ id: tagsTable.id, name: tagsTable.name })
.from(tagsTable)
.where(eq(tagsTable.id, tagId))
.limit(1);
if (!tag) {
return c.json({ error: { code: "NOT_FOUND", message: "Tag not found" } }, 404);
}
// Junction table has a composite PK — ignore re-assigns instead of erroring
await db.insert(noteTags).values({ noteId: id, tagId }).onConflictDoNothing();
await recordActivity({
actor: user.name,
action: "tagged",
entityType: "note",
entityId: id,
changes: { tagId, tagName: tag.name },
workspaceId: note.domainId,
});
return c.json({ success: true }, 201);
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
if (error instanceof z.ZodError) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400);
}
console.error("[notes] POST /:id/tags error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to assign tag" } }, 500);
}
});
// DELETE /api/notes/:id/tags/:tagId — Remove a tag from a note
noteRoutes.delete("/:id/tags/:tagId", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
const tagId = c.req.param("tagId");
const [note] = await db.select({ id: notes.id, domainId: notes.domainId })
.from(notes)
.where(and(eq(notes.id, id), isNull(notes.deletedAt)))
.limit(1);
if (!note) {
return c.json({ error: { code: "NOT_FOUND", message: "Note not found" } }, 404);
}
await requireWorkspaceAccess(c, note.domainId);
// Junction tables have no deleted_at — hard delete is correct here
await db.delete(noteTags).where(and(eq(noteTags.noteId, id), eq(noteTags.tagId, tagId)));
await recordActivity({
actor: user.name,
action: "untagged",
entityType: "note",
entityId: id,
changes: { tagId },
workspaceId: note.domainId,
});
return c.body(null, 204);
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[notes] DELETE /:id/tags/:tagId error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to remove tag" } }, 500);
}
});
// GET /api/notes/:id/backlinks — Notes that link TO this one // GET /api/notes/:id/backlinks — Notes that link TO this one
noteRoutes.get("/:id/backlinks", async (c) => { noteRoutes.get("/:id/backlinks", async (c) => {
try { try {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); const id = c.req.param("id");
const [note] = await db.select({ id: notes.id, domainId: notes.domainId })
.from(notes)
.where(and(eq(notes.id, id), isNull(notes.deletedAt)))
.limit(1);
if (!note) {
return c.json({ error: { code: "NOT_FOUND", message: "Note not found" } }, 404);
}
await requireWorkspaceAccess(c, note.domainId);
const backlinks = await getBacklinks(id); const backlinks = await getBacklinks(id);
return c.json({ return c.json({
@@ -345,11 +478,23 @@ noteRoutes.get("/:id/versions", async (c) => {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); const id = c.req.param("id");
const [note] = await db.select({ id: notes.id, domainId: notes.domainId })
.from(notes)
.where(and(eq(notes.id, id), isNull(notes.deletedAt)))
.limit(1);
if (!note) {
return c.json({ error: { code: "NOT_FOUND", message: "Note not found" } }, 404);
}
await requireWorkspaceAccess(c, note.domainId);
const versions = await db.select() const versions = await db.select()
.from(activityFeed) .from(activityFeed)
.where(and( .where(and(
eq(activityFeed.entityId, id), eq(activityFeed.entityId, id),
eq(activityFeed.entityType, "note"), eq(activityFeed.entityType, "note"),
eq(activityFeed.workspaceId, note.domainId),
)) ))
.orderBy(desc(activityFeed.createdAt)) .orderBy(desc(activityFeed.createdAt))
.limit(100); .limit(100);
+44
View File
@@ -0,0 +1,44 @@
import { Hono } from "hono";
import { db, activityFeed } from "@project-e/db";
import { and, desc, eq, gte, ne, sql } from "drizzle-orm";
import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth";
export const notificationRoutes = new Hono();
// The bell in the topbar shows a badge for activity_feed events from the last
// 7 days. There is no read/unread state yet, so "count" doubles as the unread
// badge. graph_edge rows are workspace-internal graph plumbing, not user-facing
// activity, so they are excluded from both the count and the feed.
const NOTIFICATION_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
// GET /api/notifications?workspace_id=&limit= — Recent activity for a workspace.
notificationRoutes.get("/", async (c) => {
try {
const user = await requireAuth(c);
let workspaceId = c.req.query("workspace_id");
if (!workspaceId) {
const active = await resolveActiveDomain(user);
workspaceId = active.id;
}
await requireWorkspaceAccess(c, workspaceId);
const limit = Math.min(Math.max(parseInt(c.req.query("limit") || "20", 10) || 20, 1), 100);
const since = new Date(Date.now() - NOTIFICATION_WINDOW_MS);
const conditions = [
eq(activityFeed.workspaceId, workspaceId),
gte(activityFeed.createdAt, since),
ne(activityFeed.entityType, "graph_edge"),
];
const [items, countResult] = await Promise.all([
db.select().from(activityFeed).where(and(...conditions)).orderBy(desc(activityFeed.createdAt)).limit(limit),
db.select({ count: sql<number>`count(*)` }).from(activityFeed).where(and(...conditions)),
]);
return c.json({ items, count: Number(countResult[0]?.count || 0) });
} catch (error) {
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
console.error("[notifications] GET error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get notifications" } }, 500);
}
});
+63 -3
View File
@@ -1,8 +1,9 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { db, projects, tasks, sections, projectTags, tags as tagsTable, activityFeed } from "@project-e/db"; import { db, projects, tasks, sections, projectTags, tags as tagsTable, activityFeed } from "@project-e/db";
import { and, asc, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm"; import { and, asc, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm";
import { requireAuth, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth"; import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth";
import { recordActivity } from "../middleware/activity"; import { recordActivity } from "../middleware/activity";
import { enqueueWebhooks } from "../middleware/webhook-queue";
import { z } from "zod"; import { z } from "zod";
export const projectRoutes = new Hono(); export const projectRoutes = new Hono();
@@ -69,6 +70,8 @@ projectRoutes.get("/", async (c) => {
domainId = active.id; domainId = active.id;
} }
await requireWorkspaceAccess(c, domainId);
const conditions: any[] = [ const conditions: any[] = [
eq(projects.domainId, domainId), eq(projects.domainId, domainId),
isNull(projects.deletedAt), isNull(projects.deletedAt),
@@ -189,6 +192,8 @@ projectRoutes.post("/", async (c) => {
domain: body.domain || (await resolveActiveDomain(user)).id, domain: body.domain || (await resolveActiveDomain(user)).id,
}); });
await requireWorkspaceAccess(c, data.domain);
const [project] = await db.insert(projects).values({ const [project] = await db.insert(projects).values({
name: data.name, name: data.name,
description: data.description ?? null, description: data.description ?? null,
@@ -214,6 +219,8 @@ projectRoutes.post("/", async (c) => {
workspaceId: data.domain, workspaceId: data.domain,
}); });
await enqueueWebhooks({ workspaceId: data.domain, event: "project.created", entityType: "project", entityId: project.id, data: { name: project.name } });
return c.json(project, 201); return c.json(project, 201);
} catch (error) { } catch (error) {
if (error instanceof AuthError) { if (error instanceof AuthError) {
@@ -242,6 +249,8 @@ projectRoutes.get("/:id", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
} }
await requireWorkspaceAccess(c, project.domainId);
// Fetch sections // Fetch sections
const projectSections = await db.select() const projectSections = await db.select()
.from(sections) .from(sections)
@@ -303,6 +312,8 @@ projectRoutes.patch("/:id", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
} }
await requireWorkspaceAccess(c, existing.domainId);
const updateValues: Record<string, unknown> = {}; const updateValues: Record<string, unknown> = {};
if (data.name !== undefined) updateValues.name = data.name; if (data.name !== undefined) updateValues.name = data.name;
if (data.description !== undefined) updateValues.description = data.description; if (data.description !== undefined) updateValues.description = data.description;
@@ -326,6 +337,8 @@ projectRoutes.patch("/:id", async (c) => {
workspaceId: existing.domainId, workspaceId: existing.domainId,
}); });
await enqueueWebhooks({ workspaceId: existing.domainId, event: "project.updated", entityType: "project", entityId: id, data: { ...data, previousName: existing.name } });
return c.json(updated); return c.json(updated);
} catch (error) { } catch (error) {
if (error instanceof AuthError) { if (error instanceof AuthError) {
@@ -354,6 +367,8 @@ projectRoutes.delete("/:id", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
} }
await requireWorkspaceAccess(c, existing.domainId);
await db.update(projects) await db.update(projects)
.set({ deletedAt: new Date(), updatedAt: new Date() }) .set({ deletedAt: new Date(), updatedAt: new Date() })
.where(eq(projects.id, id)); .where(eq(projects.id, id));
@@ -367,6 +382,8 @@ projectRoutes.delete("/:id", async (c) => {
workspaceId: existing.domainId, workspaceId: existing.domainId,
}); });
await enqueueWebhooks({ workspaceId: existing.domainId, event: "project.deleted", entityType: "project", entityId: id, data: { name: existing.name } });
return c.body(null, 204); return c.body(null, 204);
} catch (error) { } catch (error) {
if (error instanceof AuthError) { if (error instanceof AuthError) {
@@ -383,7 +400,7 @@ projectRoutes.get("/:id/sections", async (c) => {
const user = await requireAuth(c); const user = await requireAuth(c);
const projectId = c.req.param("id"); const projectId = c.req.param("id");
const [project] = await db.select({ id: projects.id }) const [project] = await db.select({ id: projects.id, domainId: projects.domainId })
.from(projects) .from(projects)
.where(and(eq(projects.id, projectId), isNull(projects.deletedAt))) .where(and(eq(projects.id, projectId), isNull(projects.deletedAt)))
.limit(1); .limit(1);
@@ -392,6 +409,8 @@ projectRoutes.get("/:id/sections", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
} }
await requireWorkspaceAccess(c, project.domainId);
const items = await db.select() const items = await db.select()
.from(sections) .from(sections)
.where(eq(sections.projectId, projectId)) .where(eq(sections.projectId, projectId))
@@ -424,6 +443,8 @@ projectRoutes.post("/:id/sections", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
} }
await requireWorkspaceAccess(c, project.domainId);
let sortOrder = data.sortOrder; let sortOrder = data.sortOrder;
if (sortOrder === undefined) { if (sortOrder === undefined) {
const [maxOrder] = await db.select({ max: sql<number>`COALESCE(MAX(sort_order), -1)` }) const [maxOrder] = await db.select({ max: sql<number>`COALESCE(MAX(sort_order), -1)` })
@@ -470,6 +491,17 @@ projectRoutes.get("/:id/sections/:sid", async (c) => {
const projectId = c.req.param("id"); const projectId = c.req.param("id");
const id = c.req.param("sid"); const id = c.req.param("sid");
const [project] = await db.select({ id: projects.id, domainId: projects.domainId })
.from(projects)
.where(and(eq(projects.id, projectId), isNull(projects.deletedAt)))
.limit(1);
if (!project) {
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
}
await requireWorkspaceAccess(c, project.domainId);
const [section] = await db.select() const [section] = await db.select()
.from(sections) .from(sections)
.where(and(eq(sections.id, id), eq(sections.projectId, projectId))) .where(and(eq(sections.id, id), eq(sections.projectId, projectId)))
@@ -498,6 +530,17 @@ projectRoutes.patch("/:id/sections/:sid", async (c) => {
const body = await c.req.json(); const body = await c.req.json();
const data = updateSectionSchema.parse(body); const data = updateSectionSchema.parse(body);
const [project] = await db.select({ id: projects.id, domainId: projects.domainId })
.from(projects)
.where(and(eq(projects.id, projectId), isNull(projects.deletedAt)))
.limit(1);
if (!project) {
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
}
await requireWorkspaceAccess(c, project.domainId);
const [existing] = await db.select() const [existing] = await db.select()
.from(sections) .from(sections)
.where(and(eq(sections.id, id), eq(sections.projectId, projectId))) .where(and(eq(sections.id, id), eq(sections.projectId, projectId)))
@@ -555,6 +598,17 @@ projectRoutes.delete("/:id/sections/:sid", async (c) => {
const projectId = c.req.param("id"); const projectId = c.req.param("id");
const id = c.req.param("sid"); const id = c.req.param("sid");
const [project] = await db.select({ id: projects.id, domainId: projects.domainId })
.from(projects)
.where(and(eq(projects.id, projectId), isNull(projects.deletedAt)))
.limit(1);
if (!project) {
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
}
await requireWorkspaceAccess(c, project.domainId);
const [existing] = await db.select() const [existing] = await db.select()
.from(sections) .from(sections)
.where(and(eq(sections.id, id), eq(sections.projectId, projectId))) .where(and(eq(sections.id, id), eq(sections.projectId, projectId)))
@@ -598,7 +652,7 @@ projectRoutes.get("/:id/members", async (c) => {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); const id = c.req.param("id");
const [project] = await db.select({ id: projects.id }) const [project] = await db.select({ id: projects.id, domainId: projects.domainId })
.from(projects) .from(projects)
.where(and(eq(projects.id, id), isNull(projects.deletedAt))) .where(and(eq(projects.id, id), isNull(projects.deletedAt)))
.limit(1); .limit(1);
@@ -607,6 +661,8 @@ projectRoutes.get("/:id/members", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
} }
await requireWorkspaceAccess(c, project.domainId);
// Members are stored in activity feed with entityType=member // Members are stored in activity feed with entityType=member
const members = await db.select() const members = await db.select()
.from(activityFeed) .from(activityFeed)
@@ -646,6 +702,8 @@ projectRoutes.post("/:id/members", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
} }
await requireWorkspaceAccess(c, project.domainId);
await recordActivity({ await recordActivity({
actor: user.name, actor: user.name,
action: "added", action: "added",
@@ -684,6 +742,8 @@ projectRoutes.delete("/:id/members/:uid", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
} }
await requireWorkspaceAccess(c, project.domainId);
await recordActivity({ await recordActivity({
actor: user.name, actor: user.name,
action: "removed", action: "removed",
+16
View File
@@ -1,5 +1,6 @@
import { Hono } from "hono"; import { Hono } from "hono";
import postgres from "postgres"; import postgres from "postgres";
import { requireWorkspaceAccess, AuthError } from "../middleware/auth";
export const realtimeRoutes = new Hono(); export const realtimeRoutes = new Hono();
@@ -13,6 +14,21 @@ realtimeRoutes.get("/realtime", async (c) => {
const url = new URL(c.req.url); const url = new URL(c.req.url);
const workspaceId = url.searchParams.get("workspace_id"); const workspaceId = url.searchParams.get("workspace_id");
// IDOR guard: if a workspace is requested, verify the current user actually
// owns it before subscribing to the event stream. Without this check any
// authenticated user could tail another workspace's events.
if (workspaceId) {
try {
await requireWorkspaceAccess(c, workspaceId);
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[realtime] workspace validation error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to validate workspace" } }, 500);
}
}
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const listener = postgres(process.env.DATABASE_URL!, { max: 1 }); const listener = postgres(process.env.DATABASE_URL!, { max: 1 });
let unlisten: (() => Promise<void>) | undefined; let unlisten: (() => Promise<void>) | undefined;
+17 -1
View File
@@ -1,6 +1,6 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { db, sql } from "@project-e/db"; import { db, sql } from "@project-e/db";
import { requireAuth, AuthError } from "../middleware/auth"; import { requireAuth, resolveActiveDomain, AuthError } from "../middleware/auth";
export const searchRoutes = new Hono(); export const searchRoutes = new Hono();
@@ -31,6 +31,11 @@ searchRoutes.get("/", async (c) => {
return c.json({ results: [], totalCount: 0 }); return c.json({ results: [], totalCount: 0 });
} }
// Scope all searches to the user's active domain so users can never see
// another workspace's data.
const userDomain = await resolveActiveDomain(user);
const userDomainId = userDomain.id;
const results: Array<{ id: string; type: string; title: string; snippet: string; score: number; workspaceId: string; link: string }> = []; const results: Array<{ id: string; type: string; title: string; snippet: string; score: number; workspaceId: string; link: string }> = [];
for (const type of types) { for (const type of types) {
@@ -44,6 +49,17 @@ searchRoutes.get("/", async (c) => {
conditions.push(deletedColumn + " IS NULL"); conditions.push(deletedColumn + " IS NULL");
} }
// Restrict results to the user's active domain. For `domain` the
// workspace column is the table's own `id`; for all other entities it
// is `domain_id`. userDomainId is a trusted uuid from the DB, but we
// escape single quotes defensively anyway.
const domainValue = String(userDomainId).replace(/'/g, "''");
if (type === 'domain') {
conditions.push(workspaceColumn + " = '" + domainValue + "'");
} else {
conditions.push("domain_id = '" + domainValue + "'");
}
const whereClause = conditions.join(' AND '); const whereClause = conditions.join(' AND ');
const headlineColumn = contentColumn || titleColumn; const headlineColumn = contentColumn || titleColumn;
+277 -17
View File
@@ -1,9 +1,11 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { db, tasks, taskTags, tags as tagsTable, taskDependencies, activityFeed } from "@project-e/db"; import { db, tasks, taskTags, tags as tagsTable, taskDependencies, activityFeed, scheduledJobs } from "@project-e/db";
import { and, asc, desc, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm"; import { and, asc, desc, eq, exists, ilike, inArray, isNull, or, sql } from "drizzle-orm";
import { requireAuth, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth"; import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth";
import { recordActivity } from "../middleware/activity"; import { recordActivity } from "../middleware/activity";
import { enqueueWebhooks } from "../middleware/webhook-queue";
import { z } from "zod"; import { z } from "zod";
import { RRule } from "rrule";
export const taskRoutes = new Hono(); export const taskRoutes = new Hono();
@@ -42,6 +44,38 @@ const updateTaskSchema = z.object({
recurrenceRule: z.string().optional().nullable(), recurrenceRule: z.string().optional().nullable(),
}); });
// ── Recurring tasks ────────────────────────────────────────────────────────────────
// Keeps scheduled_jobs in sync with a task's recurrence_rule so the worker's
// recurring_spawn handler has work to do. A malformed rule must never fail the
// create/update — fall back to +1 day and log.
function computeNextOccurrenceAt(recurrenceRule: string): Date {
try {
const rule = RRule.fromString(recurrenceRule);
const next = rule.after(new Date());
if (next) return next;
} catch {
// fall through to fallback
}
return new Date(Date.now() + 24 * 60 * 60 * 1000);
}
async function syncScheduledJob(taskId: string, recurrenceRule: string | null): Promise<void> {
try {
await db.delete(scheduledJobs).where(and(eq(scheduledJobs.entityType, "task"), eq(scheduledJobs.entityId, taskId)));
if (recurrenceRule) {
await db.insert(scheduledJobs).values({
entityType: "task",
entityId: taskId,
recurrenceRule,
nextOccurrenceAt: computeNextOccurrenceAt(recurrenceRule),
});
}
} catch (error) {
console.error(`[tasks] Failed to sync scheduled job for task ${taskId}:`, error);
}
}
// GET /api/tasks — List tasks with filtering, sorting, pagination // GET /api/tasks — List tasks with filtering, sorting, pagination
taskRoutes.get("/", async (c) => { taskRoutes.get("/", async (c) => {
try { try {
@@ -68,6 +102,8 @@ taskRoutes.get("/", async (c) => {
domainId = active.id; domainId = active.id;
} }
await requireWorkspaceAccess(c, domainId);
// Build conditions // Build conditions
const conditions: any[] = [ const conditions: any[] = [
eq(tasks.domainId, domainId), eq(tasks.domainId, domainId),
@@ -104,6 +140,21 @@ taskRoutes.get("/", async (c) => {
if (sectionId) { if (sectionId) {
conditions.push(eq(tasks.sectionId, sectionId)); conditions.push(eq(tasks.sectionId, sectionId));
} }
// Tag filter applied in SQL (EXISTS on the junction table) so it runs over
// the full dataset before pagination — filtering in-memory after fetching a
// page would miss tasks beyond the limit and report a wrong totalItems.
if (tag) {
const tagIds = tag.split(",").map((t) => t.trim()).filter(Boolean);
if (tagIds.length > 0) {
conditions.push(
exists(
db.select({ one: sql`1` })
.from(taskTags)
.where(and(eq(taskTags.taskId, tasks.id), inArray(taskTags.tagId, tagIds)))
)
);
}
}
// Build order // Build order
const orderFn = order === "desc" ? desc : asc; const orderFn = order === "desc" ? desc : asc;
@@ -138,21 +189,10 @@ taskRoutes.get("/", async (c) => {
const totalItems = Number(countResult[0]?.count || 0); const totalItems = Number(countResult[0]?.count || 0);
// If tag filter is specified, filter in-memory
let filteredItems = items;
if (tag) {
const tagIds = tag.split(",");
const taskTagRows = await db.select({ taskId: taskTags.taskId })
.from(taskTags)
.where(inArray(taskTags.tagId, tagIds));
const matchingTaskIds = new Set(taskTagRows.map(r => r.taskId));
filteredItems = items.filter(t => matchingTaskIds.has(t.id));
}
// Fetch tags for all tasks // Fetch tags for all tasks
let taskTagMap = new Map<string, { id: string; name: string; color: string | null }[]>(); let taskTagMap = new Map<string, { id: string; name: string; color: string | null }[]>();
if (filteredItems.length > 0) { if (items.length > 0) {
const taskIds = filteredItems.map(t => t.id); const taskIds = items.map(t => t.id);
const tagRows = await db.select({ const tagRows = await db.select({
taskId: taskTags.taskId, taskId: taskTags.taskId,
id: tagsTable.id, id: tagsTable.id,
@@ -169,7 +209,7 @@ taskRoutes.get("/", async (c) => {
} }
} }
const itemsWithTags = filteredItems.map(t => ({ const itemsWithTags = items.map(t => ({
...t, ...t,
tags: taskTagMap.get(t.id) || [], tags: taskTagMap.get(t.id) || [],
})); }));
@@ -202,6 +242,8 @@ taskRoutes.post("/", async (c) => {
domain: body.domain || (await resolveActiveDomain(user)).id, domain: body.domain || (await resolveActiveDomain(user)).id,
}); });
await requireWorkspaceAccess(c, data.domain);
// Cycle detection for parentId (subtask) // Cycle detection for parentId (subtask)
if (data.parentId) { if (data.parentId) {
const [parent] = await db.select({ id: tasks.id }) const [parent] = await db.select({ id: tasks.id })
@@ -244,6 +286,12 @@ taskRoutes.post("/", async (c) => {
workspaceId: data.domain, workspaceId: data.domain,
}); });
await enqueueWebhooks({ workspaceId: data.domain, event: "task.created", entityType: "task", entityId: task.id, data: { title: task.title } });
if (data.recurrenceRule) {
await syncScheduledJob(task.id, data.recurrenceRule);
}
return c.json(task, 201); return c.json(task, 201);
} catch (error) { } catch (error) {
if (error instanceof AuthError) { if (error instanceof AuthError) {
@@ -257,6 +305,71 @@ taskRoutes.post("/", async (c) => {
} }
}); });
const reorderTasksSchema = z.object({
orderedIds: z.array(z.string().uuid()).min(1, "orderedIds is required"),
domain: z.string().uuid().optional(),
});
// POST /api/tasks/reorder — Persist Kanban board column ordering
taskRoutes.post("/reorder", async (c) => {
try {
const user = await requireAuth(c);
const body = await c.req.json();
const { orderedIds, domain } = reorderTasksSchema.parse(body);
// Resolve the workspace: explicit domain, or the first task's domain
let workspaceId = domain;
if (!workspaceId) {
const [firstTask] = await db.select({ domainId: tasks.domainId })
.from(tasks)
.where(eq(tasks.id, orderedIds[0]))
.limit(1);
if (!firstTask) {
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
}
workspaceId = firstTask.domainId;
}
await requireWorkspaceAccess(c, workspaceId);
// Verify every task exists in this workspace and is not soft-deleted
const existing = await db.select({ id: tasks.id })
.from(tasks)
.where(and(inArray(tasks.id, orderedIds), eq(tasks.domainId, workspaceId), isNull(tasks.deletedAt)));
if (existing.length !== orderedIds.length) {
return c.json({ error: { code: "NOT_FOUND", message: "One or more tasks not found" } }, 404);
}
// Update each task's order to its index in a transaction
await db.transaction(async (tx) => {
for (let i = 0; i < orderedIds.length; i++) {
await tx.update(tasks)
.set({ order: i, updatedAt: new Date() })
.where(eq(tasks.id, orderedIds[i]));
}
});
await recordActivity({
actor: user.name,
action: "reordered",
entityType: "task",
entityId: orderedIds[0],
changes: { orderedIds },
workspaceId,
});
return c.json({ success: true, orderedIds });
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
if (error instanceof z.ZodError) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400);
}
console.error("[tasks] POST /reorder error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to reorder tasks" } }, 500);
}
});
// GET /api/tasks/:id — Get a single task with subtasks + dependencies // GET /api/tasks/:id — Get a single task with subtasks + dependencies
taskRoutes.get("/:id", async (c) => { taskRoutes.get("/:id", async (c) => {
try { try {
@@ -272,6 +385,8 @@ taskRoutes.get("/:id", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404); return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
} }
await requireWorkspaceAccess(c, task.domainId);
// Fetch subtasks // Fetch subtasks
const subtasks = await db.select() const subtasks = await db.select()
.from(tasks) .from(tasks)
@@ -341,6 +456,8 @@ taskRoutes.patch("/:id", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404); return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
} }
await requireWorkspaceAccess(c, existing.domainId);
// Cycle detection for parentId // Cycle detection for parentId
if (data.parentId && data.parentId === id) { if (data.parentId && data.parentId === id) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "A task cannot be its own parent" } }, 400); return c.json({ error: { code: "VALIDATION_ERROR", message: "A task cannot be its own parent" } }, 400);
@@ -390,6 +507,12 @@ taskRoutes.patch("/:id", async (c) => {
workspaceId: existing.domainId, workspaceId: existing.domainId,
}); });
await enqueueWebhooks({ workspaceId: existing.domainId, event: "task.updated", entityType: "task", entityId: id, data: { ...data, previousStatus: existing.status } });
if (data.recurrenceRule !== undefined) {
await syncScheduledJob(id, data.recurrenceRule);
}
return c.json(updated); return c.json(updated);
} catch (error) { } catch (error) {
if (error instanceof AuthError) { if (error instanceof AuthError) {
@@ -418,6 +541,8 @@ taskRoutes.delete("/:id", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404); return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
} }
await requireWorkspaceAccess(c, existing.domainId);
await db.update(tasks) await db.update(tasks)
.set({ deletedAt: new Date(), updatedAt: new Date() }) .set({ deletedAt: new Date(), updatedAt: new Date() })
.where(eq(tasks.id, id)); .where(eq(tasks.id, id));
@@ -431,6 +556,11 @@ taskRoutes.delete("/:id", async (c) => {
workspaceId: existing.domainId, workspaceId: existing.domainId,
}); });
await enqueueWebhooks({ workspaceId: existing.domainId, event: "task.deleted", entityType: "task", entityId: id, data: { title: existing.title } });
// Stop recurring spawns for a deleted task
await syncScheduledJob(id, null);
return c.body(null, 204); return c.body(null, 204);
} catch (error) { } catch (error) {
if (error instanceof AuthError) { if (error instanceof AuthError) {
@@ -441,6 +571,96 @@ taskRoutes.delete("/:id", async (c) => {
} }
}); });
// POST /api/tasks/:id/tags — Assign a tag to a task
taskRoutes.post("/:id/tags", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
const body = await c.req.json();
const { tagId } = z.object({ tagId: z.string().uuid("Invalid tag id") }).parse(body);
const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId })
.from(tasks)
.where(and(eq(tasks.id, id), isNull(tasks.deletedAt)))
.limit(1);
if (!task) {
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
}
await requireWorkspaceAccess(c, task.domainId);
const [tag] = await db.select({ id: tagsTable.id, name: tagsTable.name })
.from(tagsTable)
.where(eq(tagsTable.id, tagId))
.limit(1);
if (!tag) {
return c.json({ error: { code: "NOT_FOUND", message: "Tag not found" } }, 404);
}
// Junction table has a composite PK — ignore re-assigns instead of erroring
await db.insert(taskTags).values({ taskId: id, tagId }).onConflictDoNothing();
await recordActivity({
actor: user.name,
action: "tagged",
entityType: "task",
entityId: id,
changes: { tagId, tagName: tag.name },
workspaceId: task.domainId,
});
return c.json({ success: true }, 201);
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
if (error instanceof z.ZodError) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400);
}
console.error("[tasks] POST /:id/tags error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to assign tag" } }, 500);
}
});
// DELETE /api/tasks/:id/tags/:tagId — Remove a tag from a task
taskRoutes.delete("/:id/tags/:tagId", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
const tagId = c.req.param("tagId");
const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId })
.from(tasks)
.where(and(eq(tasks.id, id), isNull(tasks.deletedAt)))
.limit(1);
if (!task) {
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
}
await requireWorkspaceAccess(c, task.domainId);
// Junction tables have no deleted_at — hard delete is correct here
await db.delete(taskTags).where(and(eq(taskTags.taskId, id), eq(taskTags.tagId, tagId)));
await recordActivity({
actor: user.name,
action: "untagged",
entityType: "task",
entityId: id,
changes: { tagId },
workspaceId: task.domainId,
});
return c.body(null, 204);
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[tasks] DELETE /:id/tags/:tagId error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to remove tag" } }, 500);
}
});
// POST /api/tasks/:id/status — Change task status (Kanban drag) // POST /api/tasks/:id/status — Change task status (Kanban drag)
taskRoutes.post("/:id/status", async (c) => { taskRoutes.post("/:id/status", async (c) => {
try { try {
@@ -460,6 +680,8 @@ taskRoutes.post("/:id/status", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404); return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
} }
await requireWorkspaceAccess(c, existing.domainId);
const updateValues: Record<string, unknown> = { const updateValues: Record<string, unknown> = {
status: newStatus, status: newStatus,
updatedAt: new Date(), updatedAt: new Date(),
@@ -501,11 +723,23 @@ taskRoutes.get("/:id/history", async (c) => {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); const id = c.req.param("id");
const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId })
.from(tasks)
.where(and(eq(tasks.id, id), isNull(tasks.deletedAt)))
.limit(1);
if (!task) {
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
}
await requireWorkspaceAccess(c, task.domainId);
const history = await db.select() const history = await db.select()
.from(activityFeed) .from(activityFeed)
.where(and( .where(and(
eq(activityFeed.entityId, id), eq(activityFeed.entityId, id),
eq(activityFeed.entityType, "task"), eq(activityFeed.entityType, "task"),
eq(activityFeed.workspaceId, task.domainId),
)) ))
.orderBy(desc(activityFeed.createdAt)) .orderBy(desc(activityFeed.createdAt))
.limit(100); .limit(100);
@@ -526,11 +760,23 @@ taskRoutes.get("/:id/comments", async (c) => {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); const id = c.req.param("id");
const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId })
.from(tasks)
.where(and(eq(tasks.id, id), isNull(tasks.deletedAt)))
.limit(1);
if (!task) {
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
}
await requireWorkspaceAccess(c, task.domainId);
const comments = await db.select() const comments = await db.select()
.from(activityFeed) .from(activityFeed)
.where(and( .where(and(
eq(activityFeed.entityId, id), eq(activityFeed.entityId, id),
eq(activityFeed.entityType, "comment"), eq(activityFeed.entityType, "comment"),
eq(activityFeed.workspaceId, task.domainId),
)) ))
.orderBy(asc(activityFeed.createdAt)); .orderBy(asc(activityFeed.createdAt));
@@ -564,6 +810,8 @@ taskRoutes.post("/:id/comments", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404); return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
} }
await requireWorkspaceAccess(c, task.domainId);
await recordActivity({ await recordActivity({
actor: user.name, actor: user.name,
action: "commented", action: "commented",
@@ -592,12 +840,24 @@ taskRoutes.get("/:id/attachments", async (c) => {
const user = await requireAuth(c); const user = await requireAuth(c);
const id = c.req.param("id"); const id = c.req.param("id");
const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId })
.from(tasks)
.where(and(eq(tasks.id, id), isNull(tasks.deletedAt)))
.limit(1);
if (!task) {
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
}
await requireWorkspaceAccess(c, task.domainId);
// Attachments are stored in activity feed with entityType=attachment // Attachments are stored in activity feed with entityType=attachment
const attachments = await db.select() const attachments = await db.select()
.from(activityFeed) .from(activityFeed)
.where(and( .where(and(
eq(activityFeed.entityId, id), eq(activityFeed.entityId, id),
eq(activityFeed.entityType, "attachment"), eq(activityFeed.entityType, "attachment"),
eq(activityFeed.workspaceId, task.domainId),
)) ))
.orderBy(desc(activityFeed.createdAt)); .orderBy(desc(activityFeed.createdAt));
+18 -6
View File
@@ -1,8 +1,9 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { db, webhooks, webhookDeliveries } from "@project-e/db"; import { db, webhooks } from "@project-e/db";
import { and, asc, desc, eq, sql } from "drizzle-orm"; import { and, asc, desc, eq, sql } from "drizzle-orm";
import { requireAuth, resolveActiveDomain, AuthError } from "../middleware/auth"; import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth";
import { recordActivity } from "../middleware/activity"; import { recordActivity } from "../middleware/activity";
import { enqueueWebhookDelivery } from "../middleware/webhook-queue";
import { z } from "zod"; import { z } from "zod";
export const webhookRoutes = new Hono(); export const webhookRoutes = new Hono();
@@ -43,6 +44,7 @@ webhookRoutes.get("/", async (c) => {
const active = await resolveActiveDomain(user); const active = await resolveActiveDomain(user);
domainId = active.id; domainId = active.id;
} }
await requireWorkspaceAccess(c, domainId);
const conditions: any[] = [eq(webhooks.workspaceId, domainId)]; const conditions: any[] = [eq(webhooks.workspaceId, domainId)];
const sortField = sort.replace(/^-/, ""); const sortField = sort.replace(/^-/, "");
@@ -73,6 +75,8 @@ webhookRoutes.post("/", async (c) => {
domain: body.domain || (await resolveActiveDomain(user)).id, domain: body.domain || (await resolveActiveDomain(user)).id,
}); });
await requireWorkspaceAccess(c, data.domain);
const [webhook] = await db.insert(webhooks).values({ const [webhook] = await db.insert(webhooks).values({
name: data.name, name: data.name,
url: data.url, url: data.url,
@@ -107,6 +111,8 @@ webhookRoutes.patch("/:id", async (c) => {
const [existing] = await db.select().from(webhooks).where(eq(webhooks.id, id)).limit(1); const [existing] = await db.select().from(webhooks).where(eq(webhooks.id, id)).limit(1);
if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Webhook not found" } }, 404); if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Webhook not found" } }, 404);
await requireWorkspaceAccess(c, existing.workspaceId);
const updateValues: Record<string, unknown> = {}; const updateValues: Record<string, unknown> = {};
if (data.name !== undefined) updateValues.name = data.name; if (data.name !== undefined) updateValues.name = data.name;
if (data.url !== undefined) updateValues.url = data.url; if (data.url !== undefined) updateValues.url = data.url;
@@ -139,6 +145,8 @@ webhookRoutes.delete("/:id", async (c) => {
const [existing] = await db.select().from(webhooks).where(eq(webhooks.id, id)).limit(1); const [existing] = await db.select().from(webhooks).where(eq(webhooks.id, id)).limit(1);
if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Webhook not found" } }, 404); if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Webhook not found" } }, 404);
await requireWorkspaceAccess(c, existing.workspaceId);
await db.delete(webhooks).where(eq(webhooks.id, id)); await db.delete(webhooks).where(eq(webhooks.id, id));
await recordActivity({ await recordActivity({
@@ -162,13 +170,17 @@ webhookRoutes.post("/:id/test", async (c) => {
const [webhook] = await db.select().from(webhooks).where(eq(webhooks.id, id)).limit(1); const [webhook] = await db.select().from(webhooks).where(eq(webhooks.id, id)).limit(1);
if (!webhook) return c.json({ error: { code: "NOT_FOUND", message: "Webhook not found" } }, 404); if (!webhook) return c.json({ error: { code: "NOT_FOUND", message: "Webhook not found" } }, 404);
const testPayload = { event: "test", data: { message: "This is a test webhook from Project E", timestamp: new Date().toISOString() } }; await requireWorkspaceAccess(c, webhook.workspaceId);
await db.insert(webhookDeliveries).values({ // Enqueue a delivery job instead of inserting a delivery row directly — the
// worker performs the delivery and records the webhook_deliveries row.
await enqueueWebhookDelivery({
webhookId: id, webhookId: id,
event: "test", event: "test",
payload: testPayload, entityType: "test",
status: "pending", entityId: webhook.id,
data: { message: "This is a test webhook from Project E", timestamp: new Date().toISOString() },
workspaceId: webhook.workspaceId,
}); });
return c.json({ success: true, message: "Test webhook queued" }); return c.json({ success: true, message: "Test webhook queued" });
+2
View File
@@ -64,6 +64,7 @@
"react-force-graph-2d": "^1.29.1", "react-force-graph-2d": "^1.29.1",
"react-hook-form": "^7.84.0", "react-hook-form": "^7.84.0",
"recharts": "^3.10.1", "recharts": "^3.10.1",
"sonner": "^2.0.8",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"zod": "^4.4.3", "zod": "^4.4.3",
@@ -73,6 +74,7 @@
"@tanstack/react-query-devtools": "^5.62.0", "@tanstack/react-query-devtools": "^5.62.0",
"@tanstack/react-router-devtools": "^1.167.0", "@tanstack/react-router-devtools": "^1.167.0",
"@types/react": "^19.1.0", "@types/react": "^19.1.0",
"@types/react-big-calendar": "^1.16.3",
"@types/react-dom": "^19.1.0", "@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.5.2", "autoprefixer": "^10.5.2",
@@ -0,0 +1,199 @@
import { useEffect, useRef } from "react";
import { useApiQuery } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import type { CustomField } from "@/lib/types";
export type CustomFieldsValue = Record<string, unknown>;
interface CustomFieldInputsProps {
/**
* Plural entity type used by the custom-fields API, e.g. "tasks" | "habits".
*/
entityType: string;
values: CustomFieldsValue;
onChange: (values: CustomFieldsValue) => void;
}
/**
* Renders a type-aware input for every custom field defined for an entity.
* The entity payload carries values as `customFields: { [fieldName]: value }`,
* which this component reads and writes via `values` / `onChange`.
*
* Renders nothing when no custom fields are defined for the entity.
*/
export function CustomFieldInputs({ entityType, values, onChange }: CustomFieldInputsProps) {
const activeDomainId = useApiDomain();
const { data } = useApiQuery<{ items: CustomField[]; totalItems: number }>(
["custom-fields", entityType, activeDomainId],
"/custom-fields?entity=" + encodeURIComponent(entityType) + (activeDomainId ? "&domain=" + activeDomainId : "")
);
const fields = data?.items ?? [];
// Keep the latest values in a ref so the default-seeding effect below never
// clobbers edits the user makes while definitions refetch in the background.
const valuesRef = useRef(values);
valuesRef.current = values;
// Seed defaults for fields that have no value yet (e.g. a field created after
// the task already existed, or a brand new task with field defaults).
useEffect(() => {
if (fields.length === 0) return;
let changed = false;
const next: CustomFieldsValue = { ...valuesRef.current };
for (const field of fields) {
if (next[field.name] === undefined && field.defaultValue !== null && field.defaultValue !== undefined) {
next[field.name] = field.defaultValue;
changed = true;
}
}
if (changed) onChange(next);
// `valuesRef.current` is intentionally read (not listed) so the effect only
// runs when the field definitions change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fields, onChange]);
if (fields.length === 0) return null;
return (
<div className="space-y-4">
<Separator />
<div>
<Label className="text-sm font-semibold text-muted-foreground">Custom Fields</Label>
<div className="mt-3 space-y-4">
{fields.map((field) => (
<FieldInput
key={field.id}
field={field}
value={values[field.name]}
onValueChange={(name, value) => onChange({ ...values, [name]: value })}
/>
))}
</div>
</div>
</div>
);
}
function FieldInput({
field,
value,
onValueChange,
}: {
field: CustomField;
value: unknown;
onValueChange: (name: string, value: unknown) => void;
}) {
const inputId = "cf-" + field.name;
if (field.type === "boolean") {
return (
<div className="flex items-center gap-2">
<Checkbox id={inputId} checked={Boolean(value)} onCheckedChange={(v) => onValueChange(field.name, v === true)} />
<Label htmlFor={inputId} className="font-normal">{field.name}</Label>
</div>
);
}
if (field.type === "multi_select") {
const selected: string[] = Array.isArray(value) ? value : [];
return (
<div className="space-y-1.5">
<Label>{field.name}</Label>
<div className="space-y-1.5">
{(field.options ?? []).map((opt) => (
<div key={opt} className="flex items-center gap-2">
<Checkbox
id={inputId + "-" + opt}
checked={selected.includes(opt)}
onCheckedChange={(v) => {
const next = v ? [...selected, opt] : selected.filter((o) => o !== opt);
onValueChange(field.name, next.length > 0 ? next : null);
}}
/>
<Label htmlFor={inputId + "-" + opt} className="font-normal">{opt}</Label>
</div>
))}
</div>
</div>
);
}
return (
<div className="space-y-1.5">
<Label htmlFor={inputId}>
{field.name}
{field.required && <span className="text-destructive"> *</span>}
</Label>
{renderStandardControl(field, value, inputId, onValueChange)}
</div>
);
}
function renderStandardControl(
field: CustomField,
value: unknown,
inputId: string,
onValueChange: (name: string, value: unknown) => void
) {
switch (field.type) {
case "number": {
let numeric: number | "" = "";
if (typeof value === "number") numeric = value;
else if (value !== undefined && value !== null) {
const parsed = Number(value);
numeric = Number.isNaN(parsed) ? "" : parsed;
}
return (
<Input
id={inputId}
type="number"
value={numeric}
onChange={(e) => onValueChange(field.name, e.target.value === "" ? null : Number(e.target.value))}
required={field.required}
/>
);
}
case "date": {
return (
<Input
id={inputId}
type="date"
value={value !== undefined && value !== null ? String(value).slice(0, 10) : ""}
onChange={(e) => onValueChange(field.name, e.target.value || null)}
required={field.required}
/>
);
}
case "select": {
return (
<Select
value={typeof value === "string" ? value : ""}
onValueChange={(v) => onValueChange(field.name, v || null)}
>
<SelectTrigger id={inputId}><SelectValue placeholder="Select..." /></SelectTrigger>
<SelectContent>
{(field.options ?? []).map((opt) => (
<SelectItem key={opt} value={opt}>{opt}</SelectItem>
))}
</SelectContent>
</Select>
);
}
default: {
return (
<Input
id={inputId}
type="text"
value={value !== undefined && value !== null ? String(value) : ""}
onChange={(e) => onValueChange(field.name, e.target.value || null)}
required={field.required}
/>
);
}
}
}
@@ -0,0 +1,62 @@
import { useApiQuery } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import type { CustomField } from "@/lib/types";
interface CustomFieldsDisplayProps {
/**
* Plural entity type used by the custom-fields API, e.g. "tasks" | "habits".
*/
entityType: string;
values?: Record<string, unknown>;
}
/**
* Read-only list of an entity's custom field values. Uses the field
* definitions for labels and type-aware formatting when they are available;
* falls back to raw `name: value` rendering otherwise. Skips empty values and
* renders nothing when there is nothing to show.
*/
export function CustomFieldsDisplay({ entityType, values }: CustomFieldsDisplayProps) {
const activeDomainId = useApiDomain();
const { data } = useApiQuery<{ items: CustomField[]; totalItems: number }>(
["custom-fields", entityType, activeDomainId],
"/custom-fields?entity=" + encodeURIComponent(entityType) + (activeDomainId ? "&domain=" + activeDomainId : "")
);
if (!values) return null;
const defs = new Map((data?.items ?? []).map((f) => [f.name, f]));
const entries = Object.entries(values).filter(
([, v]) => v !== undefined && v !== null && v !== "" && !(Array.isArray(v) && v.length === 0)
);
if (entries.length === 0) return null;
return (
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Custom Fields</h3>
<div className="space-y-2">
{entries.map(([name, value]) => {
const field = defs.get(name);
return (
<div key={name} className="flex items-start gap-2 text-sm">
<span className="w-40 shrink-0 text-muted-foreground">{field?.name ?? name}</span>
<span className="flex-1 min-w-0 break-words">{formatValue(field, value)}</span>
</div>
);
})}
</div>
</div>
);
}
function formatValue(field: CustomField | undefined, value: unknown): string {
if (field) {
if (field.type === "boolean") return value ? "Yes" : "No";
if (field.type === "date" && typeof value === "string") {
const d = new Date(value);
return Number.isNaN(d.getTime()) ? value.slice(0, 10) : d.toLocaleDateString();
}
}
if (Array.isArray(value)) return value.join(", ");
return String(value);
}
@@ -0,0 +1,96 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery } from "@/lib/api";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { X } from "lucide-react";
import type { Tag, PaginatedResponse } from "@/lib/types";
const ENTITY_ROUTES: Record<"task" | "habit" | "note", string> = {
task: "tasks",
habit: "habits",
note: "notes",
};
interface TagManagerProps {
entityType: "task" | "habit" | "note";
entityId: string;
tags: Tag[];
}
/**
* Assign/remove tags on an entity from its detail page. The entity's tags come
* from the parent's query data; mutations hit the tag junction endpoints and
* refetch the entity so badges stay in sync with the API.
*/
export function TagManager({ entityType, entityId, tags }: TagManagerProps) {
const queryClient = useQueryClient();
const [addValue, setAddValue] = useState("");
const plural = ENTITY_ROUTES[entityType];
// Always fetch fresh so tags created elsewhere show up in the add dropdown.
const { data: tagsData } = useApiQuery<PaginatedResponse<Tag>>(
["tags"],
"/tags?perPage=100&sort=name",
{ staleTime: 0 }
);
const allTags = tagsData?.items || [];
const assignedIds = new Set(tags.map((t) => t.id));
const availableTags = allTags.filter((t) => !assignedIds.has(t.id));
const refreshEntity = () => {
queryClient.invalidateQueries({ queryKey: [entityType, entityId] });
};
const assignMutation = useMutation({
mutationFn: (tagId: string) => api.post(`/${plural}/${entityId}/tags`, { tagId }),
onSuccess: refreshEntity,
});
const removeMutation = useMutation({
mutationFn: (tagId: string) => api.delete(`/${plural}/${entityId}/tags/${tagId}`),
onSuccess: refreshEntity,
});
const handleAssign = (tagId: string) => {
if (!tagId || assignedIds.has(tagId)) return;
setAddValue("");
assignMutation.mutate(tagId);
};
return (
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Tags</h3>
<div className="flex flex-wrap items-center gap-2 mb-3">
{tags.length === 0 && (
<span className="text-sm text-muted-foreground">No tags</span>
)}
{tags.map((t) => (
<Badge key={t.id} variant="secondary" style={t.color ? { borderColor: t.color } : undefined}>
{t.name}
<button
type="button"
onClick={() => removeMutation.mutate(t.id)}
disabled={removeMutation.isPending}
aria-label={"Remove tag " + t.name}
className="ml-1.5 rounded-full p-0.5 text-muted-foreground hover:text-foreground hover:bg-foreground/10"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
<Select value={addValue} onValueChange={handleAssign} disabled={availableTags.length === 0}>
<SelectTrigger className="h-8 w-64 text-xs" aria-label="Add tag">
<SelectValue placeholder={availableTags.length === 0 ? "No more tags to add" : "Add tag..."} />
</SelectTrigger>
<SelectContent>
{availableTags.map((t) => (
<SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
@@ -73,9 +73,9 @@ export function CommandPalette() {
const { mode, setMode, accent, setAccent } = useThemeStore(); const { mode, setMode, accent, setAccent } = useThemeStore();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [searchResults, setSearchResults] = useState< const [searchResults, setSearchResults] = useState<
Array<{ type: string; items: Array<{ id: string; title: string }> }> Array<{ type: string; items: Array<{ id: string; title: string; link?: string }> }>
>([]); >([]);
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout>>(); const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [isMobile, setIsMobile] = useState(false); const [isMobile, setIsMobile] = useState(false);
useEffect(() => { useEffect(() => {
@@ -178,7 +178,7 @@ export function CommandPalette() {
setSearchResults([ setSearchResults([
{ {
type: "Agents", type: "Agents",
items: (data.agents || data.results || []).map((a: { id: string; name: string }) => ({ items: (data.items || []).map((a: { id: string; name: string }) => ({
id: a.id, id: a.id,
title: a.name, title: a.name,
})), })),
@@ -311,17 +311,17 @@ export function CommandPalette() {
<CommandItem <CommandItem
key={item.id} key={item.id}
onSelect={() => { onSelect={() => {
const typeRoute = // The search API returns singular types ("task", "note", ...)
group.type === "tasks" // and each result carries a ready-made detail link (e.g.
? "/tasks" // "/tasks/{id}"). Domains have no detail route, so land on the
: group.type === "habits" // dashboard (the domain-scoped home). Agent mentions have no
? "/habits" // detail page either, so just dismiss the palette.
: group.type === "projects" if (group.type === "Agents") {
? "/projects" runCommand(() => {});
: group.type === "notes" return;
? "/notes" }
: "/search"; const link = group.type === "domain" ? "/" : item.link!;
runCommand(() => navigate({ to: `${typeRoute}/${item.id}` })); runCommand(() => navigate({ to: link }));
}} }}
> >
<Search className="mr-2 h-4 w-4" /> <Search className="mr-2 h-4 w-4" />
@@ -0,0 +1,60 @@
import { useEffect } from "react";
import { useApiQuery } from "@/lib/api";
import { useActiveDomainStore } from "@/lib/stores/use-active-domain-store";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { Domain, PaginatedResponse } from "@/lib/types";
export function DomainPicker() {
const activeDomainId = useActiveDomainStore((s) => s.activeDomainId);
const setActiveDomain = useActiveDomainStore((s) => s.setActiveDomain);
const { data, isLoading } = useApiQuery<PaginatedResponse<Domain>>(["domains"], "/domains");
const domains = data?.items || [];
// The persisted selection may reference a deleted domain — validate against
// the fetched list and fall back to the first domain while unset or stale.
useEffect(() => {
if (domains.length === 0) return;
if (!activeDomainId || !domains.some((d) => d.id === activeDomainId)) {
setActiveDomain(domains[0].id);
}
}, [domains, activeDomainId, setActiveDomain]);
const empty = isLoading || domains.length === 0;
return (
<div className="hidden md:block">
<Select
value={activeDomainId ?? undefined}
onValueChange={setActiveDomain}
disabled={empty}
>
<SelectTrigger className="h-8 w-40 text-xs" aria-label="Switch domain">
<SelectValue placeholder="Domain" />
</SelectTrigger>
<SelectContent>
{domains.map((domain) => (
<SelectItem key={domain.id} value={domain.id}>
<span className="flex items-center gap-2">
{domain.color && (
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: domain.color }}
aria-hidden="true"
/>
)}
<span className="truncate">{domain.name}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
+26 -6
View File
@@ -1,3 +1,4 @@
import { useEffect, useState } from "react";
import { Link, useLocation } from "@tanstack/react-router"; import { Link, useLocation } from "@tanstack/react-router";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useSidebarStore } from "@/lib/stores/use-sidebar-store"; import { useSidebarStore } from "@/lib/stores/use-sidebar-store";
@@ -76,6 +77,24 @@ export function Sidebar() {
const location = useLocation(); const location = useLocation();
const { collapsed, toggle, mobileOpen, setMobileOpen } = useSidebarStore(); const { collapsed, toggle, mobileOpen, setMobileOpen } = useSidebarStore();
// Sidebar position (left/right) is set in Settings. Read once on mount and
// update live via the "sidebar-position-change" custom event dispatched by
// the settings page.
const [sidebarPos, setSidebarPos] = useState<"left" | "right">(() =>
typeof window !== "undefined" && localStorage.getItem("sidebar-position") === "right"
? "right"
: "left"
);
useEffect(() => {
const onSidebarPositionChange = (event: Event) => {
const detail = (event as CustomEvent<string>).detail;
if (detail === "left" || detail === "right") setSidebarPos(detail);
};
window.addEventListener("sidebar-position-change", onSidebarPositionChange);
return () => window.removeEventListener("sidebar-position-change", onSidebarPositionChange);
}, []);
const isActive = (href: string) => { const isActive = (href: string) => {
if (href === "/") return location.pathname === "/"; if (href === "/") return location.pathname === "/";
return location.pathname.startsWith(href); return location.pathname.startsWith(href);
@@ -114,7 +133,7 @@ export function Sidebar() {
return ( return (
<Tooltip key={item.href}> <Tooltip key={item.href}>
<TooltipTrigger asChild>{link}</TooltipTrigger> <TooltipTrigger asChild>{link}</TooltipTrigger>
<TooltipContent side="right">{item.label}</TooltipContent> <TooltipContent side={sidebarPos === "right" ? "left" : "right"}>{item.label}</TooltipContent>
</Tooltip> </Tooltip>
); );
} }
@@ -143,8 +162,9 @@ export function Sidebar() {
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
<aside <aside
className={cn( className={cn(
"hidden flex-col border-r bg-card transition-all duration-200 md:flex", "hidden flex-col bg-card transition-all duration-200 md:flex",
collapsed ? "w-16" : "w-60" collapsed ? "w-16" : "w-60",
sidebarPos === "right" ? "order-last border-l" : "border-r"
)} )}
aria-label="Main navigation" aria-label="Main navigation"
> >
@@ -188,7 +208,7 @@ export function Sidebar() {
</Avatar> </Avatar>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start" className="w-48"> <DropdownMenuContent side={sidebarPos === "right" ? "left" : "right"} align="start" className="w-48">
<DropdownMenuItem onClick={() => {}}> <DropdownMenuItem onClick={() => {}}>
<User className="mr-2 h-4 w-4" /> <User className="mr-2 h-4 w-4" />
Profile Profile
@@ -214,7 +234,7 @@ export function Sidebar() {
<span className="text-sm font-medium">User</span> <span className="text-sm font-medium">User</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start" className="w-48"> <DropdownMenuContent side={sidebarPos === "right" ? "left" : "right"} align="start" className="w-48">
<DropdownMenuItem onClick={() => {}}> <DropdownMenuItem onClick={() => {}}>
<User className="mr-2 h-4 w-4" /> <User className="mr-2 h-4 w-4" />
Profile Profile
@@ -236,7 +256,7 @@ export function Sidebar() {
{/* Mobile sheet */} {/* Mobile sheet */}
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}> <Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetContent side="left" className="flex w-72 flex-col p-0 md:hidden"> <SheetContent side={sidebarPos === "right" ? "right" : "left"} className="flex w-72 flex-col p-0 md:hidden">
<SheetHeader className="border-b px-4 py-4 pr-12"> <SheetHeader className="border-b px-4 py-4 pr-12">
<SheetTitle>Project E</SheetTitle> <SheetTitle>Project E</SheetTitle>
<SheetDescription>Navigate your workspace.</SheetDescription> <SheetDescription>Navigate your workspace.</SheetDescription>
+90 -12
View File
@@ -1,6 +1,10 @@
import { Search, Bell, Plus, Menu } from "lucide-react"; import { Search, Bell, Plus, Menu, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useSidebarStore } from "@/lib/stores/use-sidebar-store"; import { useSidebarStore } from "@/lib/stores/use-sidebar-store";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { useApiQuery } from "@/lib/api";
import { useQueryClient } from "@tanstack/react-query";
import { DomainPicker } from "@/components/shell/domain-picker";
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { import {
DropdownMenu, DropdownMenu,
@@ -15,14 +19,45 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { formatDistanceToNow } from "date-fns";
import type { NotificationsResponse } from "@/lib/types";
const ACTION_LABELS: Record<string, string> = {
created: "created",
updated: "updated",
deleted: "deleted",
completed: "completed",
};
function readableEntityType(entityType: string): string {
return entityType
.replace(/_/g, " ")
.replace(/\b\w/g, (ch) => ch.toUpperCase());
}
export function Topbar() { export function Topbar() {
const { setMobileOpen } = useSidebarStore(); const { setMobileOpen } = useSidebarStore();
const queryClient = useQueryClient();
const domainId = useApiDomain();
const openPalette = () => { const openPalette = () => {
document.dispatchEvent(new CustomEvent("open-command-palette")); document.dispatchEvent(new CustomEvent("open-command-palette"));
}; };
const { data: notificationsData } = useApiQuery<NotificationsResponse>(
["notifications", domainId],
"/notifications?workspace_id=" + encodeURIComponent(domainId),
{ enabled: !!domainId, refetchInterval: 60_000 }
);
const notifications = notificationsData?.items || [];
const count = notificationsData?.count || 0;
const badgeLabel = count > 99 ? "99+" : String(count);
const tooltipText =
count === 0
? "No notifications"
: `${count} unread notification${count === 1 ? "" : "s"}`;
return ( return (
<header <header
className="sticky top-0 z-10 flex h-14 items-center gap-4 border-b bg-card px-6" className="sticky top-0 z-10 flex h-14 items-center gap-4 border-b bg-card px-6"
@@ -55,6 +90,9 @@ export function Topbar() {
</Button> </Button>
</div> </div>
{/* Domain picker */}
<DomainPicker />
{/* Right side */} {/* Right side */}
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
{/* Quick add */} {/* Quick add */}
@@ -76,17 +114,57 @@ export function Topbar() {
{/* Notifications bell */} {/* Notifications bell */}
<TooltipProvider> <TooltipProvider>
<Tooltip> <DropdownMenu>
<TooltipTrigger asChild> <Tooltip>
<Button variant="ghost" size="icon" className="relative" aria-label="Notifications"> <TooltipTrigger asChild>
<Bell className="h-5 w-5" /> <DropdownMenuTrigger asChild>
<span className="absolute -right-0.5 -top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-destructive text-[10px] font-medium text-destructive-foreground"> <Button variant="ghost" size="icon" className="relative" aria-label="Notifications">
0 <Bell className="h-5 w-5" />
</span> {count > 0 && (
</Button> <span
</TooltipTrigger> aria-hidden="true"
<TooltipContent>No notifications</TooltipContent> className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-medium text-destructive-foreground"
</Tooltip> >
{badgeLabel}
</span>
)}
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>{tooltipText}</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="w-80">
<div className="flex items-center justify-between px-2 py-1.5">
<span className="text-sm font-medium">Notifications</span>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => queryClient.invalidateQueries({ queryKey: ["notifications"] })}
>
<RefreshCw className="h-3 w-3 mr-1" />Refresh
</Button>
</div>
<DropdownMenuSeparator />
{notifications.length === 0 ? (
<div className="px-3 py-8 text-center text-sm text-muted-foreground">
No notifications
</div>
) : (
notifications.slice(0, 10).map((n) => (
<DropdownMenuItem key={n.id} className="flex cursor-default flex-col items-start gap-0.5 py-2">
<span className="text-sm capitalize">
{readableEntityType(n.entityType)}{" "}
{ACTION_LABELS[n.action] || n.action}
</span>
<span className="text-xs text-muted-foreground">
{n.actor} &middot; {formatDistanceToNow(new Date(n.createdAt), { addSuffix: true })}
</span>
</DropdownMenuItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
</TooltipProvider> </TooltipProvider>
{/* User avatar */} {/* User avatar */}
+64
View File
@@ -0,0 +1,64 @@
import type { ReactNode } from "react";
import { Loader2, Inbox, AlertCircle, RotateCw, type LucideIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
/**
* Centered loading state with a spinner and optional label.
* Drop-in replacement for inline `<div className="py-12 text-center">Loading...</div>` markup.
*/
export function LoadingState({ label }: { label?: string }) {
return (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin" />
{label ? <p className="text-sm">{label}</p> : null}
</div>
);
}
/**
* Centered empty state: icon, title, optional description and optional CTA.
* `action` is rendered below the text (e.g. a Button that opens a create dialog).
*/
export function EmptyState({
icon: Icon = Inbox,
title,
description,
action,
}: {
icon?: LucideIcon;
title: string;
description?: string;
action?: ReactNode;
}) {
return (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<Icon className="h-6 w-6 text-muted-foreground" />
</div>
<div className="space-y-1">
<p className="font-semibold">{title}</p>
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
{action ? <div className="pt-1">{action}</div> : null}
</div>
);
}
/**
* Centered error state with a destructive-styled message and an optional
* retry button that re-runs the failed query.
*/
export function ErrorState({ message, onRetry }: { message: string; onRetry?: () => void }) {
return (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
<AlertCircle className="h-8 w-8 text-destructive" />
<p className="text-sm font-medium text-destructive">{message}</p>
{onRetry ? (
<Button variant="outline" size="sm" onClick={onRetry}>
<RotateCw className="h-4 w-4" />
Retry
</Button>
) : null}
</div>
);
}
+9 -6
View File
@@ -13,7 +13,7 @@ export function useRealtime(options: UseRealtimeOptions = {}) {
const { workspaceId, enabled = true } = options; const { workspaceId, enabled = true } = options;
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const eventSourceRef = useRef<EventSource | null>(null); const eventSourceRef = useRef<EventSource | null>(null);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout>>(); const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const reconnectAttempts = useRef(0); const reconnectAttempts = useRef(0);
const handleEvent = useCallback( const handleEvent = useCallback(
@@ -23,19 +23,22 @@ export function useRealtime(options: UseRealtimeOptions = {}) {
switch (entityType) { switch (entityType) {
case "task": case "task":
queryKeys.push(["tasks"]); queryKeys.push(["tasks"], ["tasks-due"], ["stats"], ["productivity-chart"]);
break; break;
case "habit": case "habit":
queryKeys.push(["habits"]); queryKeys.push(["habits"], ["habits-today"], ["streaks"]);
break; break;
case "project": case "project":
queryKeys.push(["projects"]); queryKeys.push(["projects"], ["active-projects"]);
break; break;
case "note": case "note":
queryKeys.push(["notes"]); queryKeys.push(["notes"], ["recent-notes"]);
break; break;
case "calendar_event": case "calendar_event":
queryKeys.push(["calendar-events"]); queryKeys.push(["calendar-events"], ["upcoming-events"]);
break;
case "dashboard_widget":
queryKeys.push(["dashboard-widgets"]);
break; break;
case "graph_edge": case "graph_edge":
queryKeys.push(["graph"]); queryKeys.push(["graph"]);
+70
View File
@@ -1,9 +1,18 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap");
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer base { @layer base {
:root { :root {
/* Typography — Inter via Google Fonts @import above; falls back to system-ui */
--font-sans: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
/* Density — scales content spacing (see the density utilities below and the
calc() padding on <main> in the app layout). 1 = comfortable. */
--density-scale: 1;
--background: 0 0% 100%; --background: 0 0% 100%;
--foreground: 222.2 84% 4.9%; --foreground: 222.2 84% 4.9%;
--card: 0 0% 100%; --card: 0 0% 100%;
@@ -27,6 +36,17 @@
--accent-hsl: 217 91% 60%; --accent-hsl: 217 91% 60%;
} }
/* Density modes: toggled on <html> by the settings page (and applied from
localStorage on app load). They only flip --density-scale, which the
layout padding and the space-y utilities below multiply by. */
.density-compact {
--density-scale: 0.85;
}
.density-spacious {
--density-scale: 1.15;
}
.dark { .dark {
--background: 222.2 84% 4.9%; --background: 222.2 84% 4.9%;
--foreground: 210 40% 98%; --foreground: 210 40% 98%;
@@ -57,5 +77,55 @@
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
}
/* Real reduced-motion support: the settings page toggles `.reduce-motion` on
<html>. This standard override collapses animation/transition durations so
users who enable it get an effectively static UI. */
.reduce-motion *,
.reduce-motion *::before,
.reduce-motion *::after {
animation-duration: 0.001s !important;
transition-duration: 0.001s !important;
animation-iteration-count: 1 !important;
}
@layer utilities {
/* Density: multiply the vertical rhythm between stacked sections inside page
content. Mirrors Tailwind's own `.space-y-*` selector shape and is scoped
to <main> so the sidebar and topbar stay fixed. Combined with the calc()
padding on <main> in the app layout, the Density setting now visibly
changes spacing (0.85x compact, 1.15x spacious). */
.density-compact main .space-y-1 > :not([hidden]) ~ :not([hidden]),
.density-spacious main .space-y-1 > :not([hidden]) ~ :not([hidden]) {
margin-top: calc(0.25rem * var(--density-scale));
}
.density-compact main .space-y-2 > :not([hidden]) ~ :not([hidden]),
.density-spacious main .space-y-2 > :not([hidden]) ~ :not([hidden]) {
margin-top: calc(0.5rem * var(--density-scale));
}
.density-compact main .space-y-3 > :not([hidden]) ~ :not([hidden]),
.density-spacious main .space-y-3 > :not([hidden]) ~ :not([hidden]) {
margin-top: calc(0.75rem * var(--density-scale));
}
.density-compact main .space-y-4 > :not([hidden]) ~ :not([hidden]),
.density-spacious main .space-y-4 > :not([hidden]) ~ :not([hidden]) {
margin-top: calc(1rem * var(--density-scale));
}
.density-compact main .space-y-5 > :not([hidden]) ~ :not([hidden]),
.density-spacious main .space-y-5 > :not([hidden]) ~ :not([hidden]) {
margin-top: calc(1.25rem * var(--density-scale));
}
.density-compact main .space-y-6 > :not([hidden]) ~ :not([hidden]),
.density-spacious main .space-y-6 > :not([hidden]) ~ :not([hidden]) {
margin-top: calc(1.5rem * var(--density-scale));
}
.density-compact main .space-y-8 > :not([hidden]) ~ :not([hidden]),
.density-spacious main .space-y-8 > :not([hidden]) ~ :not([hidden]) {
margin-top: calc(2rem * var(--density-scale));
} }
} }
+4
View File
@@ -40,6 +40,10 @@ async function apiFetch<T>(
throw new Error(error.message); throw new Error(error.message);
} }
// Some routes return 204 No Content (e.g. deletes). res.json() throws on an
// empty body, so return undefined for those instead.
if (res.status === 204) return undefined as T;
return res.json(); return res.json();
} }
+70
View File
@@ -0,0 +1,70 @@
/**
* Shared semantic color tokens for entities (status, priority, type).
*
* Single source of truth — pages must import these instead of defining local
* color maps. All values are Tailwind classes unless the comment says otherwise.
*/
export interface StatusToken {
label: string;
/** Tailwind class for a small colored dot (e.g. `w-2 h-2 rounded-full`). */
dot: string;
/** Tailwind classes for a filled Badge (bg + text). */
badge: string;
}
/** Task workflow statuses (board column dots + badges). */
export const TASK_STATUS: Record<string, StatusToken> = {
todo: { label: "Todo", dot: "bg-slate-500", badge: "bg-slate-500 text-white" },
in_progress: { label: "In Progress", dot: "bg-blue-500", badge: "bg-blue-500 text-white" },
done: { label: "Done", dot: "bg-green-500", badge: "bg-green-500 text-white" },
cancelled: { label: "Cancelled", dot: "bg-red-500", badge: "bg-red-500 text-white" },
};
/** Task priority. Badges use a soft tint (matching text + translucent bg). */
export const PRIORITY: Record<string, { label: string; badge: string }> = {
low: { label: "Low", badge: "text-slate-500 bg-slate-500/10" },
medium: { label: "Medium", badge: "text-blue-500 bg-blue-500/10" },
high: { label: "High", badge: "text-orange-500 bg-orange-500/10" },
urgent: { label: "Urgent", badge: "text-red-500 bg-red-500/10" },
};
/** Project lifecycle statuses. */
export const PROJECT_STATUS: Record<string, StatusToken> = {
active: { label: "Active", dot: "bg-green-500", badge: "bg-green-500 text-white" },
paused: { label: "Paused", dot: "bg-amber-500", badge: "bg-amber-500 text-white" },
completed: { label: "Completed", dot: "bg-blue-500", badge: "bg-blue-500 text-white" },
archived: { label: "Archived", dot: "bg-slate-500", badge: "bg-slate-500 text-white" },
};
/**
* Graph node colors by entity type (hex).
*
* These are consumed by the canvas renderer (`ctx.fillStyle`) and inline
* `style={{ backgroundColor }}` props. CSS variables don't work in canvas
* `fillStyle`, so keep literal hex values here.
*/
export const ENTITY: Record<string, string> = {
task: "#3b82f6",
habit: "#10b981",
project: "#8b5cf6",
note: "#f59e0b",
section: "#ec4899",
tag: "#6b7280",
domain: "#6366f1",
};
/**
* Calendar event colors by entity type.
*
* Values are HSL triplets consumed as `hsl(${hue})` / `hsla(${hue}, 0.15)`.
* `task` resolves through the theme accent (`var(--accent-hsl)`), so events
* pick up the user's chosen accent color.
*/
export const CALENDAR_EVENT: Record<string, string> = {
task: "var(--accent-hsl, 217 91% 60%)",
habit: "142 71% 45%",
project: "271 81% 56%",
note: "24 95% 53%",
default: "215 16% 47%",
};
@@ -0,0 +1,38 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { useApiQuery } from "@/lib/api";
interface ActiveDomainState {
activeDomainId: string | null;
setActiveDomain: (id: string | null) => void;
}
export const useActiveDomainStore = create<ActiveDomainState>()(
persist(
(set) => ({
activeDomainId: null,
setActiveDomain: (id) => set({ activeDomainId: id }),
}),
{
name: "project-e-active-domain",
}
)
);
// Raw selector: reads the persisted value (may reference a deleted domain).
export function useActiveDomainId(): string | null {
return useActiveDomainStore((s) => s.activeDomainId);
}
// Resolved active domain id for API calls. Validates the persisted value
// against the user's domains (falling back to the first domain) so consumers
// never query a domain that no longer exists. Returns "" until domains load.
// Shares the ["domains"] query cache, so it adds no extra network requests.
export function useApiDomain(): string {
const activeDomainId = useActiveDomainId();
const { data } = useApiQuery<{ items: { id: string }[] }>(["domains"], "/domains");
const items = data?.items || [];
if (items.length === 0) return "";
if (activeDomainId && items.some((d) => d.id === activeDomainId)) return activeDomainId;
return items[0].id;
}
+42 -4
View File
@@ -18,6 +18,7 @@ export interface Task {
updatedAt: string; updatedAt: string;
deletedAt: string | null; deletedAt: string | null;
tags: Tag[]; tags: Tag[];
customFields?: Record<string, unknown>;
subtasks?: Task[]; subtasks?: Task[];
dependencies?: { id: string; title: string; status: string }[]; dependencies?: { id: string; title: string; status: string }[];
dependents?: { id: string; title: string; status: string }[]; dependents?: { id: string; title: string; status: string }[];
@@ -133,6 +134,8 @@ export interface GraphNode {
label: string; label: string;
type: string; type: string;
color: string; color: string;
x?: number;
y?: number;
} }
export interface GraphEdge { export interface GraphEdge {
@@ -259,10 +262,27 @@ export interface Agent {
domainId: string; domainId: string;
tags: string[]; tags: string[];
config: Record<string, unknown>; config: Record<string, unknown>;
apiKey?: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
export interface Notification {
id: string;
actor: string;
action: string;
entityType: string;
entityId: string;
changes: Record<string, unknown> | null;
workspaceId: string;
createdAt: string;
}
export interface NotificationsResponse {
items: Notification[];
count: number;
}
export interface AgentActivity { export interface AgentActivity {
id: string; id: string;
agentId: string; agentId: string;
@@ -343,10 +363,28 @@ export interface HabitAnalytics {
period: number; period: number;
} }
export interface ProjectAnalytics { export interface DailyAnalyticsItem {
taskCompletionRate: number; date: string;
totalTasks: number; created: number;
completedTasks: number; completed: number;
}
export interface DailyAnalytics {
items: DailyAnalyticsItem[];
period: number;
}
export interface ProjectProgress {
id: string;
name: string;
totalTasks: number;
completedTasks: number;
progress: number;
}
export interface ProjectAnalytics {
projects: ProjectProgress[];
totalProjects: number;
period: number; period: number;
} }
+2
View File
@@ -1,10 +1,12 @@
import { createRootRoute, Outlet } from "@tanstack/react-router"; import { createRootRoute, Outlet } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import { Toaster } from "@/components/ui/sonner";
export const Route = createRootRoute({ export const Route = createRootRoute({
component: () => ( component: () => (
<div className="min-h-screen bg-background text-foreground"> <div className="min-h-screen bg-background text-foreground">
<Outlet /> <Outlet />
<Toaster />
{import.meta.env.DEV && <TanStackRouterDevtools />} {import.meta.env.DEV && <TanStackRouterDevtools />}
</div> </div>
), ),
+14 -1
View File
@@ -1,3 +1,4 @@
import { useEffect } from "react";
import { createRoute, Outlet } from "@tanstack/react-router"; import { createRoute, Outlet } from "@tanstack/react-router";
import { Route as rootRoute } from "./__root"; import { Route as rootRoute } from "./__root";
import { Sidebar } from "@/components/shell/sidebar"; import { Sidebar } from "@/components/shell/sidebar";
@@ -9,6 +10,18 @@ import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
function AppLayout() { function AppLayout() {
useKeyboardShortcuts(); useKeyboardShortcuts();
// Apply persisted appearance preferences (density, reduced motion) right
// after the first paint. The settings page updates these live while open;
// this covers reloads where the settings page was never visited.
useEffect(() => {
const root = document.documentElement;
root.classList.remove("density-compact", "density-spacious");
const density = localStorage.getItem("density");
if (density === "compact") root.classList.add("density-compact");
if (density === "spacious") root.classList.add("density-spacious");
if (localStorage.getItem("reduced-motion") === "true") root.classList.add("reduce-motion");
}, []);
return ( return (
<div className="flex min-h-screen"> <div className="flex min-h-screen">
<Sidebar /> <Sidebar />
@@ -16,7 +29,7 @@ function AppLayout() {
<Topbar /> <Topbar />
<main <main
id="main-content" id="main-content"
className="flex-1 overflow-auto p-4 md:p-6" className="flex-1 overflow-auto p-[calc(1rem*var(--density-scale))] md:p-[calc(1.5rem*var(--density-scale))]"
tabIndex={-1} tabIndex={-1}
> >
<Outlet /> <Outlet />
+17 -4
View File
@@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery, useApiMutation } from "@/lib/api"; import { api, useApiQuery, useApiMutation } from "@/lib/api";
import { Bot, Filter, Calendar, RefreshCw, ExternalLink, Clock, Activity } from "lucide-react"; import { Bot, Filter, Calendar, RefreshCw, ExternalLink, Clock, Activity } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { LoadingState, EmptyState } from "@/components/state";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
@@ -57,8 +58,20 @@ function AgentActivityPage() {
es.onmessage = (event) => { es.onmessage = (event) => {
try { try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
if (data.type === "agent_activity" || data.type === "activity") { // Realtime events are flat: { type: entityType, action, id, workspace_id }.
setLiveActivities((prev) => [data.payload, ...prev].slice(0, 5)); // Match only agent events so unrelated task/habit/etc. activity doesn't leak in.
if (data.type === "agent") {
const entry: AgentActivity = {
id: `${data.id}-live-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
agentId: data.id,
action: data.action,
description: `Live update: ${data.action}`,
entityType: "agent",
entityId: data.id,
metadata: null,
createdAt: new Date().toISOString(),
};
setLiveActivities((prev) => [entry, ...prev].slice(0, 5));
} }
} catch {} } catch {}
}; };
@@ -117,9 +130,9 @@ function AgentActivityPage() {
{/* Timeline */} {/* Timeline */}
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center py-12 text-muted-foreground">Loading activity...</div> <LoadingState label="Loading activity..." />
) : activities.length === 0 ? ( ) : activities.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">No activity found</div> <EmptyState title="No activity found" description="Agent activity will appear here as agents run" />
) : ( ) : (
<div className="space-y-1"> <div className="space-y-1">
{activities.map((a, idx) => ( {activities.map((a, idx) => (
+67 -51
View File
@@ -1,16 +1,16 @@
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { createRoute } from "@tanstack/react-router"; import { createRoute } from "@tanstack/react-router";
import { Route as appRoute } from "../_app"; import { Route as appRoute } from "../_app";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useApiQuery } from "@/lib/api";
import { api, useApiQuery, useApiMutation } from "@/lib/api"; import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { Download, Calendar, TrendingUp, BarChart3, PieChart, Activity, Grid3X3 } from "lucide-react"; import { Download, Calendar } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { LoadingState, ErrorState } from "@/components/state";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ProductivityData, HabitAnalytics, ProjectAnalytics } from "@/lib/types"; import type { DailyAnalytics, HabitAnalytics, ProjectAnalytics } from "@/lib/types";
import { format, subDays, parseISO, startOfMonth, eachDayOfInterval } from "date-fns"; import { format, subDays } from "date-fns";
// Simple SVG-based charts (no recharts dependency needed for basic charts) // Simple SVG-based charts (no recharts dependency needed for basic charts)
function LineChart({ data, xKey, yKey, color = "#3b82f6", height = 120 }: { data: any[]; xKey: string; yKey: string; color?: string; height?: number }) { function LineChart({ data, xKey, yKey, color = "#3b82f6", height = 120 }: { data: any[]; xKey: string; yKey: string; color?: string; height?: number }) {
@@ -34,19 +34,27 @@ function LineChart({ data, xKey, yKey, color = "#3b82f6", height = 120 }: { data
); );
} }
function BarChart({ data, xKey, yKey, color = "#3b82f6", height = 120 }: { data: any[]; xKey: string; yKey: string; color?: string; height?: number }) { function BarChart({ data, xKey, yKey, yKey2, color = "#3b82f6", color2 = "#f97316", height = 120 }: { data: any[]; xKey: string; yKey: string; yKey2?: string; color?: string; color2?: string; height?: number }) {
if (!data.length) return <p className="text-sm text-muted-foreground text-center py-8">No data</p>; if (!data.length) return <p className="text-sm text-muted-foreground text-center py-8">No data</p>;
const maxVal = Math.max(...data.map((d) => d[yKey]), 1); const valOf = (d: any, key?: string) => (key ? ((d[key] as number) ?? 0) : 0);
const barWidth = Math.max(20, Math.min(40, (300 / data.length))); const maxVal = Math.max(...data.map((d) => Math.max(valOf(d, yKey), valOf(d, yKey2))), 1);
const width = Math.max(data.length * (barWidth + 4) + 40, 200); const series = yKey2 ? 2 : 1;
const barWidth = Math.max(20, Math.min(40, (300 / data.length) / series));
const width = Math.max(data.length * (barWidth * series + 4) + 40, 200);
return ( return (
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto" aria-label="Bar chart"> <svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto" aria-label="Bar chart">
{data.map((d, i) => { {data.map((d, i) => {
const barH = (d[yKey] / maxVal) * (height - 30); const barH = (valOf(d, yKey) / maxVal) * (height - 30);
const x = i * (barWidth + 4) + 20; const x = i * (barWidth * series + 4) + 20;
const y = height - 20 - barH; const y = height - 20 - barH;
return <rect key={i} x={x} y={y} width={barWidth} height={barH} fill={color} rx="2" />; return <rect key={i} x={x} y={y} width={barWidth} height={barH} fill={color} rx="2" />;
})} })}
{yKey2 && data.map((d, i) => {
const barH = (valOf(d, yKey2) / maxVal) * (height - 30);
const x = i * (barWidth * series + 4) + 20 + barWidth;
const y = height - 20 - barH;
return <rect key={i} x={x} y={y} width={barWidth} height={barH} fill={color2} rx="2" />;
})}
</svg> </svg>
); );
} }
@@ -129,23 +137,22 @@ function CalendarHeatmap({ data, days = 30 }: { data: any[]; days?: number }) {
function AnalyticsPage() { function AnalyticsPage() {
const [range, setRange] = useState("30"); const [range, setRange] = useState("30");
const activeDomainId = useApiDomain();
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
const { data: prodData } = useApiQuery<ProductivityData>(["analytics-productivity", range], "/analytics/productivity?range=" + range); const { data: habitData, isLoading: habitsLoading, error: habitsError, refetch: refetchHabits } = useApiQuery<HabitAnalytics>(["analytics-habits", activeDomainId, range], "/analytics/habits?range=" + range + domainSuffix);
const { data: habitData } = useApiQuery<HabitAnalytics>(["analytics-habits", range], "/analytics/habits?range=" + range); const { data: projectData, isLoading: projectsLoading, error: projectsError, refetch: refetchProjects } = useApiQuery<ProjectAnalytics>(["analytics-projects", activeDomainId, range], "/analytics/projects?range=" + range + domainSuffix);
const { data: projectData } = useApiQuery<ProjectAnalytics>(["analytics-projects", range], "/analytics/projects?range=" + range); const { data: dailyData, isLoading: dailyLoading, error: dailyError, refetch: refetchDaily } = useApiQuery<DailyAnalytics>(["analytics-daily", activeDomainId, range], "/analytics/daily?range=" + range + domainSuffix);
// Generate mock daily data for charts (real API returns aggregated, we simulate daily breakdown) const analyticsLoading = habitsLoading || projectsLoading || dailyLoading;
const dailyData = useMemo(() => { const analyticsError = habitsError || projectsError || dailyError;
const days = parseInt(range); const refetchAnalytics = () => {
return Array.from({ length: days }, (_, i) => { refetchHabits();
const d = subDays(new Date(), days - 1 - i); refetchProjects();
return { refetchDaily();
date: format(d, "yyyy-MM-dd"), };
completed: Math.floor(Math.random() * 5),
created: Math.floor(Math.random() * 8) + 1, const dailyItems = dailyData?.items || [];
};
});
}, [range]);
const habitRateData = useMemo(() => { const habitRateData = useMemo(() => {
return [ return [
@@ -182,15 +189,20 @@ function AnalyticsPage() {
</Select> </Select>
</div> </div>
{analyticsLoading ? (
<LoadingState label="Loading analytics..." />
) : analyticsError ? (
<ErrorState message={analyticsError.message || "Failed to load analytics"} onRetry={refetchAnalytics} />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Tasks completed per day */} {/* Tasks completed per day */}
<Card> <Card>
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between"> <CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
<CardTitle className="text-sm font-semibold">Tasks Completed</CardTitle> <CardTitle className="text-sm font-semibold">Tasks Completed</CardTitle>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("tasks-completed.csv", [["Date", "Completed"], ...dailyData.map((d) => [d.date, String(d.completed)])])}><Download className="h-3.5 w-3.5" /></Button> <Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("tasks-completed.csv", [["Date", "Completed"], ...dailyItems.map((d) => [d.date, String(d.completed)])])}><Download className="h-3.5 w-3.5" /></Button>
</CardHeader> </CardHeader>
<CardContent className="p-4"> <CardContent className="p-4">
<LineChart data={dailyData} xKey="date" yKey="completed" /> <LineChart data={dailyItems} xKey="date" yKey="completed" />
</CardContent> </CardContent>
</Card> </Card>
@@ -198,10 +210,10 @@ function AnalyticsPage() {
<Card> <Card>
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between"> <CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
<CardTitle className="text-sm font-semibold">Created vs Completed</CardTitle> <CardTitle className="text-sm font-semibold">Created vs Completed</CardTitle>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("tasks-created-vs-completed.csv", [["Date", "Created", "Completed"], ...dailyData.map((d) => [d.date, String(d.created), String(d.completed)])])}><Download className="h-3.5 w-3.5" /></Button> <Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("tasks-created-vs-completed.csv", [["Date", "Created", "Completed"], ...dailyItems.map((d) => [d.date, String(d.created), String(d.completed)])])}><Download className="h-3.5 w-3.5" /></Button>
</CardHeader> </CardHeader>
<CardContent className="p-4"> <CardContent className="p-4">
<BarChart data={dailyData} xKey="date" yKey="created" color="#f97316" /> <BarChart data={dailyItems} xKey="date" yKey="created" yKey2="completed" color="#f97316" color2="#3b82f6" />
<div className="flex items-center gap-4 mt-2 text-xs text-muted-foreground"> <div className="flex items-center gap-4 mt-2 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><div className="w-2.5 h-2.5 rounded bg-orange-500" /> Created</span> <span className="flex items-center gap-1"><div className="w-2.5 h-2.5 rounded bg-orange-500" /> Created</span>
<span className="flex items-center gap-1"><div className="w-2.5 h-2.5 rounded bg-blue-500" /> Completed</span> <span className="flex items-center gap-1"><div className="w-2.5 h-2.5 rounded bg-blue-500" /> Completed</span>
@@ -224,34 +236,37 @@ function AnalyticsPage() {
<Card> <Card>
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between"> <CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
<CardTitle className="text-sm font-semibold">Project Progress</CardTitle> <CardTitle className="text-sm font-semibold">Project Progress</CardTitle>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("project-progress.csv", [["Metric", "Value"], ["Rate", String(projectData?.taskCompletionRate || 0)], ["Total", String(projectData?.totalTasks || 0)], ["Completed", String(projectData?.completedTasks || 0)]])}><Download className="h-3.5 w-3.5" /></Button> <Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("project-progress.csv", [["Project", "Total Tasks", "Completed", "Progress"], ...(projectData?.projects || []).map((p) => [p.name, String(p.totalTasks), String(p.completedTasks), String(Math.round(p.progress * 100)) + "%"])])}><Download className="h-3.5 w-3.5" /></Button>
</CardHeader> </CardHeader>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="grid grid-cols-3 gap-2 text-center"> {projectData?.projects.length ? (
<div className="p-2 bg-muted/50 rounded"> <div className="space-y-2">
<p className="text-lg font-bold">{projectData?.taskCompletionRate || 0}%</p> <p className="text-xs text-muted-foreground">{projectData.totalProjects} project{projectData.totalProjects === 1 ? "" : "s"} · progress is % of tasks done</p>
<p className="text-[10px] text-muted-foreground">Rate</p> <HorizontalBar
data={projectData.projects.map((p) => ({ name: p.name, progress: Math.round(p.progress * 100) }))}
xKey="name"
yKey="progress"
height={Math.max(100, projectData.projects.length * 26)}
/>
</div> </div>
<div className="p-2 bg-muted/50 rounded"> ) : (
<p className="text-lg font-bold">{projectData?.totalTasks || 0}</p> <p className="text-sm text-muted-foreground text-center py-8">No projects yet</p>
<p className="text-[10px] text-muted-foreground">Total</p> )}
</div>
<div className="p-2 bg-muted/50 rounded">
<p className="text-lg font-bold text-green-500">{projectData?.completedTasks || 0}</p>
<p className="text-[10px] text-muted-foreground">Done</p>
</div>
</div>
</CardContent> </CardContent>
</Card> </Card>
{/* Time spent per domain (pie) */} {/* Tasks by project (pie) */}
<Card> <Card>
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between"> <CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
<CardTitle className="text-sm font-semibold">Time per Domain</CardTitle> <CardTitle className="text-sm font-semibold">Tasks by Project</CardTitle>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("time-per-domain.csv", [["Domain", "Tasks"], ["Default", String(prodData?.totalTasks || 0)]])}><Download className="h-3.5 w-3.5" /></Button> <Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("tasks-by-project.csv", [["Project", "Total Tasks"], ...(projectData?.projects || []).map((p) => [p.name, String(p.totalTasks)])])}><Download className="h-3.5 w-3.5" /></Button>
</CardHeader> </CardHeader>
<CardContent className="p-4"> <CardContent className="p-4">
<PieChartSimple data={[{ name: "Default", value: prodData?.totalTasks || 1 }]} labelKey="name" valueKey="value" /> {projectData?.projects.length ? (
<PieChartSimple data={projectData.projects.map((p) => ({ name: p.name, value: p.totalTasks }))} labelKey="name" valueKey="value" />
) : (
<p className="text-sm text-muted-foreground text-center py-8">No projects yet</p>
)}
</CardContent> </CardContent>
</Card> </Card>
@@ -259,13 +274,14 @@ function AnalyticsPage() {
<Card> <Card>
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between"> <CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
<CardTitle className="text-sm font-semibold">Productivity Heatmap</CardTitle> <CardTitle className="text-sm font-semibold">Productivity Heatmap</CardTitle>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("heatmap.csv", [["Date", "Count"], ...dailyData.map((d) => [d.date, String(d.completed)])])}><Download className="h-3.5 w-3.5" /></Button> <Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("heatmap.csv", [["Date", "Count"], ...dailyItems.map((d) => [d.date, String(d.completed)])])}><Download className="h-3.5 w-3.5" /></Button>
</CardHeader> </CardHeader>
<CardContent className="p-4"> <CardContent className="p-4">
<CalendarHeatmap data={dailyData.map((d) => ({ date: d.date, count: d.completed }))} days={parseInt(range)} /> <CalendarHeatmap data={dailyItems.map((d) => ({ date: d.date, count: d.completed }))} days={parseInt(range)} />
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
)}
</div> </div>
); );
} }
+86 -45
View File
@@ -1,11 +1,14 @@
import { useState, useMemo, useCallback, useEffect, useRef } from "react"; import { useState, useMemo, useCallback, useEffect, useRef } from "react";
import { createRoute } from "@tanstack/react-router"; import { createRoute } from "@tanstack/react-router";
import { Route as appRoute } from "../_app"; import { Route as appRoute } from "../_app";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient, useMutation } from "@tanstack/react-query";
import { api, useApiQuery, useApiMutation } from "@/lib/api"; import { api, useApiQuery, useApiMutation } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { useRealtime } from "@/hooks/use-realtime"; import { useRealtime } from "@/hooks/use-realtime";
import { Plus, Trash2, ChevronLeft, ChevronRight } from "lucide-react"; import { Plus, Trash2, ChevronLeft, ChevronRight } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { LoadingState, EmptyState } from "@/components/state";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -14,9 +17,10 @@ import type { CalendarEvent } from "@/lib/types";
import { format, parseISO, addDays, startOfWeek, getDay } from "date-fns"; import { format, parseISO, addDays, startOfWeek, getDay } from "date-fns";
// react-big-calendar // react-big-calendar
import { Calendar, dateFnsLocalizer, Views, Navigate } from "react-big-calendar"; import { Calendar, dateFnsLocalizer, Navigate, type View, type stringOrDate } from "react-big-calendar";
// import withDragAndDrop from "react-big-calendar/lib/addons/dragAndDrop"; import withDragAndDrop from "react-big-calendar/lib/addons/dragAndDrop";
import "react-big-calendar/lib/css/react-big-calendar.css"; import "react-big-calendar/lib/css/react-big-calendar.css";
import "react-big-calendar/lib/addons/dragAndDrop/styles.css";
const localizer = dateFnsLocalizer({ const localizer = dateFnsLocalizer({
startOfWeek, startOfWeek,
@@ -25,7 +29,10 @@ const localizer = dateFnsLocalizer({
locales: {}, locales: {},
}); });
// const DragAndDropCalendar = withDragAndDrop(Calendar); // Mapped calendar event shape used by react-big-calendar (adds Date start/end accessors)
type CalendarViewEvent = CalendarEvent & { start: Date; end: Date };
const DragAndDropCalendar = withDragAndDrop<CalendarViewEvent>(Calendar);
// Color palette: tasks=accent, events by domain // Color palette: tasks=accent, events by domain
const EVENT_COLORS: Record<string, string> = { const EVENT_COLORS: Record<string, string> = {
@@ -95,11 +102,21 @@ function EventForm({ event, onClose }: { event?: CalendarEvent; onClose: () => v
const [color, setColor] = useState(event?.color || "#3b82f6"); const [color, setColor] = useState(event?.color || "#3b82f6");
const createMutation = useApiMutation<CalendarEvent, any>("post", "/calendar/events", { const createMutation = useApiMutation<CalendarEvent, any>("post", "/calendar/events", {
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["calendar-events"] }); onClose(); }, onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
toast.success("Event created");
onClose();
},
onError: (err) => toast.error(err.message || "Failed to create event"),
}); });
const updateMutation = useApiMutation<CalendarEvent, any>("patch", event ? `/calendar/events/${event.id}` : "", { const updateMutation = useApiMutation<CalendarEvent, any>("patch", event ? `/calendar/events/${event.id}` : "", {
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["calendar-events"] }); onClose(); }, onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
toast.success("Event updated");
onClose();
},
onError: (err) => toast.error(err.message || "Failed to update event"),
}); });
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
@@ -147,7 +164,7 @@ function EventForm({ event, onClose }: { event?: CalendarEvent; onClose: () => v
function CalendarPage() { function CalendarPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [date, setDate] = useState(new Date()); const [date, setDate] = useState(new Date());
const [view, setView] = useState<string>("month"); const [view, setView] = useState<View>("month");
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null); const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null);
const [eventDetailOpen, setEventDetailOpen] = useState(false); const [eventDetailOpen, setEventDetailOpen] = useState(false);
@@ -163,19 +180,27 @@ function CalendarPage() {
}, []); }, []);
const { data: eventsData } = useApiQuery<{ items: CalendarEvent[]; totalItems: number }>( const activeDomainId = useApiDomain();
["calendar-events", date.toISOString()],
`/calendar/events?from=${new Date(0).toISOString()}&to=${new Date("2100-01-01").toISOString()}` const { data: eventsData, isLoading: eventsLoading } = useApiQuery<{ items: CalendarEvent[]; totalItems: number }>(
["calendar-events", activeDomainId, date.toISOString()],
`/calendar/events?from=${new Date(0).toISOString()}&to=${new Date("2100-01-01").toISOString()}` + (activeDomainId ? "&domain=" + activeDomainId : "")
); );
const events = eventsData?.items || []; const events = eventsData?.items || [];
const deleteMutation = useApiMutation<any, string>("delete", "", { const deleteMutation = useMutation({
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["calendar-events"] }); setEventDetailOpen(false); }, mutationFn: (id: string) => api.delete("/calendar/events/" + id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
setEventDetailOpen(false);
toast.success("Event deleted");
},
onError: (err) => toast.error(err.message || "Failed to delete event"),
}); });
// Map API events to react-big-calendar format // Map API events to react-big-calendar format
const calendarEvents = useMemo(() => { const calendarEvents = useMemo<CalendarViewEvent[]>(() => {
return events.map((evt) => ({ return events.map((evt) => ({
...evt, ...evt,
start: parseISO(evt.startTime), start: parseISO(evt.startTime),
@@ -193,10 +218,10 @@ function CalendarPage() {
}, []); }, []);
const handleEventDrop = useCallback( const handleEventDrop = useCallback(
({ event, start, end }: { event: any; start: Date; end: Date }) => { ({ event, start, end }: { event: any; start: stringOrDate; end: stringOrDate }) => {
api.patch(`/calendar/events/${event.id}`, { api.patch(`/calendar/events/${event.id}`, {
startTime: start.toISOString(), startTime: start instanceof Date ? start.toISOString() : new Date(start).toISOString(),
endTime: end.toISOString(), endTime: end instanceof Date ? end.toISOString() : new Date(end).toISOString(),
}).then(() => { }).then(() => {
queryClient.invalidateQueries({ queryKey: ["calendar-events"] }); queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
}); });
@@ -205,10 +230,10 @@ function CalendarPage() {
); );
const handleEventResize = useCallback( const handleEventResize = useCallback(
({ event, start, end }: { event: any; start: Date; end: Date }) => { ({ event, start, end }: { event: any; start: stringOrDate; end: stringOrDate }) => {
api.patch(`/calendar/events/${event.id}`, { api.patch(`/calendar/events/${event.id}`, {
startTime: start.toISOString(), startTime: start instanceof Date ? start.toISOString() : new Date(start).toISOString(),
endTime: end.toISOString(), endTime: end instanceof Date ? end.toISOString() : new Date(end).toISOString(),
}).then(() => { }).then(() => {
queryClient.invalidateQueries({ queryKey: ["calendar-events"] }); queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
}); });
@@ -221,7 +246,7 @@ function CalendarPage() {
}, []); }, []);
const handleViewChange = useCallback((newView: string) => { const handleViewChange = useCallback((newView: string) => {
setView(newView); setView(newView as View);
}, []); }, []);
return ( return (
@@ -245,38 +270,54 @@ function CalendarPage() {
key={name} key={name}
variant={view === name ? "default" : "outline"} variant={view === name ? "default" : "outline"}
size="sm" size="sm"
onClick={() => setView(name)} onClick={() => setView(name as View)}
className="capitalize" className="capitalize"
> >
{name} {name}
</Button> </Button>
))} ))}
</div> </div>
<div className="rbc-calendar-container" style={{ minHeight: isMobile ? 400 : 600 }}> {eventsLoading && events.length === 0 ? (
<Calendar <LoadingState label="Loading events..." />
key={view} ) : !eventsLoading && events.length === 0 ? (
localizer={localizer} <EmptyState
events={calendarEvents} title="No events yet"
startAccessor="start" description="Create your first event to get started"
endAccessor="end" action={
date={date} <Button onClick={() => setCreateOpen(true)}>
view={view} <Plus className="h-4 w-4 mr-2" />New Event
onNavigate={handleNavigate} </Button>
onSelectEvent={handleSelectEvent} }
onSelectSlot={handleSelectSlot}
selectable
popup
showMultiDayTimes
components={{
event: EventComponent,
toolbar: (props: any) => <CustomToolbar {...props} />,
}}
views={["month", "week", "work_week", "day", "agenda"]}
step={30}
timeslots={2}
style={{ height: isMobile ? 400 : 600 }}
/> />
</div> ) : (
<div className="rbc-calendar-container" style={{ minHeight: isMobile ? 400 : 600 }}>
<DragAndDropCalendar
key={view}
localizer={localizer}
events={calendarEvents}
startAccessor="start"
endAccessor="end"
date={date}
view={view}
onNavigate={handleNavigate}
onSelectEvent={handleSelectEvent}
onSelectSlot={handleSelectSlot}
onEventDrop={handleEventDrop}
onEventResize={handleEventResize}
selectable
popup
showMultiDayTimes
components={{
event: EventComponent,
toolbar: (props: any) => <CustomToolbar {...props} />,
}}
views={["month", "week", "work_week", "day", "agenda"]}
step={30}
timeslots={2}
style={{ height: isMobile ? 400 : 600 }}
/>
</div>
)}
{/* Event detail dialog */} {/* Event detail dialog */}
<Dialog open={eventDetailOpen} onOpenChange={setEventDetailOpen}> <Dialog open={eventDetailOpen} onOpenChange={setEventDetailOpen}>
+104 -31
View File
@@ -1,7 +1,7 @@
import { useState, useCallback, useRef, useEffect } from "react"; import { useState, useCallback, useRef, useEffect } from "react";
import { createRoute } from "@tanstack/react-router"; import { createRoute, useNavigate } from "@tanstack/react-router";
import { Route as appRoute } from "../_app"; import { Route as appRoute } from "../_app";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery, useApiMutation } from "@/lib/api"; import { api, useApiQuery, useApiMutation } from "@/lib/api";
import { Plus, Trash2, GripVertical, Type, Heading1, Heading2, List, CheckSquare, Code, Image, FileText, ArrowUp, ArrowDown, Bold, Italic } from "lucide-react"; import { Plus, Trash2, GripVertical, Type, Heading1, Heading2, List, CheckSquare, Code, Image, FileText, ArrowUp, ArrowDown, Bold, Italic } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -186,13 +186,26 @@ function BlockEditor({ block, onChange, onDelete, onMoveUp, onMoveDown }: {
// ─── Canvas Editor ──────────────────────────────────────────────────────── // ─── Canvas Editor ────────────────────────────────────────────────────────
function CanvasEditor({ canvas, onBack }: { canvas: Canvas; onBack: () => void }) { type Block = { id: string; type: string; content: string };
export function CanvasEditor({ canvas, onBack }: { canvas: Canvas; onBack: () => void }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [blocks, setBlocks] = useState<Array<{ id: string; type: string; content: string }>>( const [blocks, setBlocks] = useState<Block[]>(
canvas.cards?.map((c) => ({ id: c.id, type: c.type, content: c.content })) || [{ id: "new-1", type: "text", content: "" }] canvas.cards?.map((c) => ({ id: c.id, type: c.type, content: c.content })) || [{ id: "new-1", type: "text", content: "" }]
); );
const [title, setTitle] = useState(canvas.name); const [title, setTitle] = useState(canvas.name);
const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error">("idle");
const blockIdCounter = useRef(blocks.length + 1); const blockIdCounter = useRef(blocks.length + 1);
const lastSavedTitle = useRef(canvas.name);
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const isDirty = useRef(false);
const lastSeen = useRef<{ blocks: Block[]; title: string }>({ blocks, title });
// Keep latest values reachable from the debounced save without stale closures
const blocksRef = useRef(blocks);
blocksRef.current = blocks;
const titleRef = useRef(title);
titleRef.current = title;
// Listen for block type changes // Listen for block type changes
useEffect(() => { useEffect(() => {
@@ -204,10 +217,84 @@ function CanvasEditor({ canvas, onBack }: { canvas: Canvas; onBack: () => void }
return () => window.removeEventListener("change-block-type", handler); return () => window.removeEventListener("change-block-type", handler);
}, []); }, []);
const updateMutation = useMutation({ // Persist the full block list via the bulk endpoint, then adopt the
mutationFn: (data: any) => api.patch("/canvas/" + canvas.id, data), // server-generated ids for newly created blocks (content stays local).
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["canvas"] }), const persist = useCallback(async () => {
}); const currentBlocks = blocksRef.current;
const currentTitle = titleRef.current;
setSaveState("saving");
try {
const payload = {
cards: currentBlocks.map((b, i) => ({
...(b.id.startsWith("new-") || b.id.startsWith("block-") ? {} : { id: b.id }),
type: b.type,
content: b.content,
zIndex: i,
})),
};
const result = await api.put<{ cards: { id: string }[] }>("/canvas/" + canvas.id + "/cards", payload);
setBlocks((prev) => {
if (result.cards.length !== prev.length) return prev;
let changed = false;
const next = prev.map((b, i) => {
const newId = result.cards[i]?.id;
if (newId && b.id !== newId) {
changed = true;
return { ...b, id: newId };
}
return b;
});
return changed ? next : prev;
});
if (currentTitle !== lastSavedTitle.current) {
await api.patch("/canvas/" + canvas.id, { name: currentTitle });
lastSavedTitle.current = currentTitle;
}
queryClient.invalidateQueries({ queryKey: ["canvas"] });
isDirty.current = false;
setSaveState("saved");
} catch (error) {
console.error("[canvas] save failed:", error);
setSaveState("error");
}
}, [canvas.id, queryClient]);
// Debounced autosave: persist shortly after blocks/title stop changing
useEffect(() => {
if (lastSeen.current.blocks === blocks && lastSeen.current.title === title) {
return;
}
lastSeen.current = { blocks, title };
isDirty.current = true;
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
saveTimer.current = null;
persist();
}, 800);
return () => {
if (saveTimer.current) {
clearTimeout(saveTimer.current);
saveTimer.current = null;
}
};
}, [blocks, title, persist]);
// Flush any unsaved edits when leaving the editor
useEffect(() => {
return () => {
if (isDirty.current) {
persist();
}
};
}, [persist]);
const handleSave = () => {
if (saveTimer.current) {
clearTimeout(saveTimer.current);
saveTimer.current = null;
}
persist();
};
const handleBlockChange = (id: string, content: string) => { const handleBlockChange = (id: string, content: string) => {
setBlocks((prev) => prev.map((b) => b.id === id ? { ...b, content } : b)); setBlocks((prev) => prev.map((b) => b.id === id ? { ...b, content } : b));
@@ -240,10 +327,6 @@ function CanvasEditor({ canvas, onBack }: { canvas: Canvas; onBack: () => void }
setBlocks((prev) => [...prev, { id: "block-" + blockIdCounter.current, type, content: "" }]); setBlocks((prev) => [...prev, { id: "block-" + blockIdCounter.current, type, content: "" }]);
}; };
const handleSave = () => {
updateMutation.mutate({ name: title });
};
return ( return (
<div className="max-w-3xl mx-auto space-y-4"> <div className="max-w-3xl mx-auto space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -252,11 +335,15 @@ function CanvasEditor({ canvas, onBack }: { canvas: Canvas; onBack: () => void }
<Input <Input
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
onBlur={handleSave}
className="text-xl font-bold border-none bg-transparent h-auto px-0 focus-visible:ring-0" className="text-xl font-bold border-none bg-transparent h-auto px-0 focus-visible:ring-0"
/> />
</div> </div>
<Button size="sm" onClick={handleSave} disabled={updateMutation.isPending}>Save</Button> <div className="flex items-center gap-2">
{saveState === "saving" && <span className="text-xs text-muted-foreground">Saving&hellip;</span>}
{saveState === "saved" && <span className="text-xs text-muted-foreground">Saved</span>}
{saveState === "error" && <span className="text-xs text-destructive">Save failed</span>}
<Button size="sm" onClick={handleSave} disabled={saveState === "saving"}>Save</Button>
</div>
</div> </div>
<Separator /> <Separator />
<div className="space-y-1"> <div className="space-y-1">
@@ -292,7 +379,7 @@ function CanvasEditor({ canvas, onBack }: { canvas: Canvas; onBack: () => void }
function CanvasList() { function CanvasList() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [selectedCanvas, setSelectedCanvas] = useState<Canvas | null>(null); const navigate = useNavigate();
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [newName, setNewName] = useState(""); const [newName, setNewName] = useState("");
@@ -305,7 +392,7 @@ function CanvasList() {
queryClient.invalidateQueries({ queryKey: ["canvas"] }); queryClient.invalidateQueries({ queryKey: ["canvas"] });
setCreateOpen(false); setCreateOpen(false);
setNewName(""); setNewName("");
setSelectedCanvas(canvas); navigate({ to: "/canvas/$id", params: { id: canvas.id } });
}, },
}); });
@@ -314,20 +401,6 @@ function CanvasList() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["canvas"] }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["canvas"] }),
}); });
const openCanvas = async (id: string) => {
try {
const detail = await api.get<Canvas>("/canvas/" + id);
setSelectedCanvas(detail);
} catch {
const c = canvases.find((c) => c.id === id);
if (c) setSelectedCanvas(c);
}
};
if (selectedCanvas) {
return <CanvasEditor canvas={selectedCanvas} onBack={() => setSelectedCanvas(null)} />;
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -356,7 +429,7 @@ function CanvasList() {
) : ( ) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{canvases.map((c) => ( {canvases.map((c) => (
<Card key={c.id} className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => openCanvas(c.id)}> <Card key={c.id} className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => navigate({ to: "/canvas/$id", params: { id: c.id } })}>
<CardHeader className="p-4 pb-2"> <CardHeader className="p-4 pb-2">
<CardTitle className="text-sm font-semibold truncate">{c.name}</CardTitle> <CardTitle className="text-sm font-semibold truncate">{c.name}</CardTitle>
</CardHeader> </CardHeader>
+2 -50
View File
@@ -1,13 +1,8 @@
import { createRoute, useParams, useNavigate } from "@tanstack/react-router"; import { createRoute, useParams, useNavigate } from "@tanstack/react-router";
import { Route as appRoute } from "../../_app"; import { Route as appRoute } from "../../_app";
import { useApiQuery } from "@/lib/api"; import { useApiQuery } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { ArrowLeft, Layout, Layers } from "lucide-react";
import type { Canvas } from "@/lib/types"; import type { Canvas } from "@/lib/types";
import { format, parseISO } from "date-fns"; import { CanvasEditor } from "../canvas";
function CanvasDetail() { function CanvasDetail() {
const { id } = useParams({ from: Route.id }); const { id } = useParams({ from: Route.id });
@@ -17,50 +12,7 @@ function CanvasDetail() {
if (isLoading) return <div className="p-8 text-center text-muted-foreground">Loading...</div>; if (isLoading) return <div className="p-8 text-center text-muted-foreground">Loading...</div>;
if (!canvas) return <div className="p-8 text-center text-muted-foreground">Canvas not found</div>; if (!canvas) return <div className="p-8 text-center text-muted-foreground">Canvas not found</div>;
const blockCount = canvas.cards?.length || 0; return <CanvasEditor key={canvas.id} canvas={canvas} onBack={() => navigate({ to: "/canvas" })} />;
return (
<div className="max-w-2xl mx-auto p-6 space-y-6">
<Button variant="ghost" onClick={() => navigate({ to: "/canvas" })} className="w-fit">
<ArrowLeft className="h-4 w-4 mr-2" /> Back to Canvas
</Button>
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<Layout className="h-6 w-6 text-muted-foreground" />
<CardTitle className="text-2xl">{canvas.name}</CardTitle>
<Badge variant="secondary">{canvas.mode}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{canvas.description && (
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-1">Description</h3>
<p className="text-sm whitespace-pre-wrap">{canvas.description}</p>
</div>
)}
<Separator />
<div className="grid grid-cols-2 gap-4 text-center">
<div className="p-3 bg-muted/50 rounded-lg">
<Layers className="h-5 w-5 mx-auto mb-1 text-muted-foreground" />
<p className="text-2xl font-bold">{blockCount}</p>
<p className="text-xs text-muted-foreground">Blocks</p>
</div>
<div className="p-3 bg-muted/50 rounded-lg">
<Layout className="h-5 w-5 mx-auto mb-1 text-muted-foreground" />
<p className="text-2xl font-bold capitalize">{canvas.mode}</p>
<p className="text-xs text-muted-foreground">Mode</p>
</div>
</div>
<Separator />
<div className="text-xs text-muted-foreground space-y-1">
<p>Created: {format(parseISO(canvas.createdAt), "MMM d, yyyy HH:mm")}</p>
<p>Updated: {format(parseISO(canvas.updatedAt), "MMM d, yyyy HH:mm")}</p>
</div>
</CardContent>
</Card>
</div>
);
} }
export const Route = createRoute({ export const Route = createRoute({
+112 -15
View File
@@ -3,17 +3,18 @@ import { createRoute } from "@tanstack/react-router";
import { Route as appRoute } from "../_app"; import { Route as appRoute } from "../_app";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery, useApiMutation } from "@/lib/api"; import { api, useApiQuery, useApiMutation } from "@/lib/api";
import { Calendar, ChevronLeft, ChevronRight, Plus, Save, Smile, Zap } from "lucide-react"; import { Calendar, ChevronLeft, ChevronRight, Plus, Save, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { DailyNote } from "@/lib/types"; import type { DailyNote } from "@/lib/types";
import { format, parseISO, startOfMonth, endOfMonth, eachDayOfInterval, getDay, isSameDay, isToday, addMonths, subMonths } from "date-fns"; import { format, startOfMonth, endOfMonth, eachDayOfInterval, getDay, isSameDay, isToday, addMonths, subMonths } from "date-fns";
// ─── Calendar Sidebar ──────────────────────────────────────────────────── // ─── Calendar Sidebar ────────────────────────────────────────────────────
@@ -27,7 +28,12 @@ function CalendarSidebar({ selectedDate, onSelectDate }: { selectedDate: Date; o
// Check which dates have notes // Check which dates have notes
const { data } = useApiQuery<{ items: DailyNote[]; totalItems: number }>(["daily-notes-list"], "/daily-notes"); const { data } = useApiQuery<{ items: DailyNote[]; totalItems: number }>(["daily-notes-list"], "/daily-notes");
const notes = data?.items || []; const notes = data?.items || [];
const noteDates = new Set(notes.map((n) => format(parseISO(n.date), "yyyy-MM-dd"))); // The API stores daily notes at UTC midnight (YYYY-MM-DDT00:00:00.000Z).
// Slicing off the time portion yields the calendar date the note belongs to
// regardless of the browser's timezone. parseISO + format would re-render the
// UTC instant in the local zone and shift the marker to the previous day for
// users west of UTC.
const noteDates = new Set(notes.map((n) => n.date.slice(0, 10)));
return ( return (
<div className="w-64 shrink-0"> <div className="w-64 shrink-0">
@@ -88,8 +94,16 @@ function DailyNoteEditor({ date }: { date: Date }) {
const [energy, setEnergy] = useState<number | null>(null); const [energy, setEnergy] = useState<number | null>(null);
const [noteId, setNoteId] = useState<string | null>(null); const [noteId, setNoteId] = useState<string | null>(null);
const [isNew, setIsNew] = useState(false); const [isNew, setIsNew] = useState(false);
const [saveTimer, setSaveTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const noteIdRef = useRef<string | null>(null);
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const prevDateStrRef = useRef(dateStr);
// Mirror noteId into a ref so a pending autosave timer can always read the
// latest id. Without this, a timer scheduled while no note existed yet would
// fire with a stale null and double-create the note once createMutation
// resolves (noteId is set asynchronously in onSuccess).
noteIdRef.current = noteId;
const { data: note, isLoading } = useApiQuery<DailyNote | null>( const { data: note, isLoading } = useApiQuery<DailyNote | null>(
["daily-note", dateStr], ["daily-note", dateStr],
@@ -97,6 +111,17 @@ function DailyNoteEditor({ date }: { date: Date }) {
); );
useEffect(() => { useEffect(() => {
// Switching days must cancel any pending autosave so it can't fire against
// the newly loaded note (or with the previous day's closure state). The
// guard on prevDateStrRef keeps refetches of the same day from wiping a
// debounce that is still in flight.
if (prevDateStrRef.current !== dateStr) {
prevDateStrRef.current = dateStr;
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
}
if (note) { if (note) {
setContent(note.content || ""); setContent(note.content || "");
setMood(note.mood); setMood(note.mood);
@@ -112,6 +137,17 @@ function DailyNoteEditor({ date }: { date: Date }) {
} }
}, [note, isLoading, dateStr]); }, [note, isLoading, dateStr]);
// Clear any pending autosave when the editor unmounts so a stale timer can't
// fire after navigation away from the page.
useEffect(() => {
return () => {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
};
}, []);
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: (data: any) => api.post<DailyNote>("/daily-notes", data), mutationFn: (data: any) => api.post<DailyNote>("/daily-notes", data),
onSuccess: (saved) => { onSuccess: (saved) => {
@@ -131,16 +167,17 @@ function DailyNoteEditor({ date }: { date: Date }) {
}); });
const autoSave = useCallback((newContent: string, newMood: number | null, newEnergy: number | null) => { const autoSave = useCallback((newContent: string, newMood: number | null, newEnergy: number | null) => {
if (saveTimer) clearTimeout(saveTimer); if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
const timer = setTimeout(() => { saveTimerRef.current = setTimeout(() => {
if (noteId) { saveTimerRef.current = null;
updateMutation.mutate({ id: noteId, data: { content: newContent, mood: newMood, energy: newEnergy } }); const id = noteIdRef.current;
if (id) {
updateMutation.mutate({ id, data: { content: newContent, mood: newMood, energy: newEnergy } });
} else if (newContent.trim()) { } else if (newContent.trim()) {
createMutation.mutate({ date: dateStr, content: newContent, mood: newMood, energy: newEnergy }); createMutation.mutate({ date: dateStr, content: newContent, mood: newMood, energy: newEnergy });
} }
}, 1500); }, 1500);
setSaveTimer(timer); }, [dateStr, updateMutation, createMutation]);
}, [noteId, dateStr, saveTimer]);
const handleContentChange = (value: string) => { const handleContentChange = (value: string) => {
setContent(value); setContent(value);
@@ -151,6 +188,10 @@ function DailyNoteEditor({ date }: { date: Date }) {
setMood(value); setMood(value);
if (noteId) { if (noteId) {
updateMutation.mutate({ id: noteId, data: { mood: value } }); updateMutation.mutate({ id: noteId, data: { mood: value } });
} else if (isNew && !createMutation.isPending) {
// No note exists for this day yet — create it so the mood is recorded
// even before any content is typed.
createMutation.mutate({ date: dateStr, content: content, mood: value, energy: energy });
} }
}; };
@@ -158,18 +199,68 @@ function DailyNoteEditor({ date }: { date: Date }) {
setEnergy(value); setEnergy(value);
if (noteId) { if (noteId) {
updateMutation.mutate({ id: noteId, data: { energy: value } }); updateMutation.mutate({ id: noteId, data: { energy: value } });
} else if (isNew && !createMutation.isPending) {
// No note exists for this day yet — create it so the energy is recorded
// even before any content is typed.
createMutation.mutate({ date: dateStr, content: content, mood: mood, energy: value });
} }
}; };
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
try {
await api.delete("/daily-notes/" + id);
} catch (error) {
// The API responds 204 No Content, which has no JSON body, so api.delete
// (which resolves res.json()) rejects with a SyntaxError on the empty
// body even though the server-side delete succeeded. Re-throw anything
// else (real HTTP/network failures).
if (!(error instanceof SyntaxError)) throw error;
}
},
onSuccess: () => {
setContent("");
setMood(null);
setEnergy(null);
setNoteId(null);
setIsNew(true);
queryClient.invalidateQueries({ queryKey: ["daily-note", dateStr] });
queryClient.invalidateQueries({ queryKey: ["daily-notes-list"] });
},
});
const handleDelete = () => {
if (noteId) deleteMutation.mutate(noteId);
};
return ( return (
<div className="flex-1 space-y-4"> <div className="flex-1 space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-xl font-bold">{format(date, "EEEE, MMMM d, yyyy")}</h2> <h2 className="text-xl font-bold">{format(date, "EEEE, MMMM d, yyyy")}</h2>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{noteId && ( {noteId && (
<Badge variant="secondary" className="text-[10px]"> <>
<Save className="h-3 w-3 mr-1" />Saved <Badge variant="secondary" className="text-[10px]">
</Badge> <Save className="h-3 w-3 mr-1" />Saved
</Badge>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" aria-label="Delete daily note">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Daily Note</AlertDialogTitle>
<AlertDialogDescription>Are you sure you want to delete this daily note? This cannot be undone.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground">Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)} )}
</div> </div>
</div> </div>
@@ -178,10 +269,13 @@ function DailyNoteEditor({ date }: { date: Date }) {
<div className="flex gap-6"> <div className="flex gap-6">
<div> <div>
<p className="text-xs text-muted-foreground mb-1">Mood</p> <p className="text-xs text-muted-foreground mb-1">Mood</p>
<div className="flex gap-1"> <div role="radiogroup" aria-label="Mood" className="flex gap-1">
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => ( {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => (
<button <button
key={v} key={v}
role="radio"
aria-checked={mood === v}
aria-label={`Mood ${v}`}
onClick={() => handleMoodChange(v)} onClick={() => handleMoodChange(v)}
className={cn( className={cn(
"w-6 h-6 rounded text-[10px] font-medium transition-colors", "w-6 h-6 rounded text-[10px] font-medium transition-colors",
@@ -195,10 +289,13 @@ function DailyNoteEditor({ date }: { date: Date }) {
</div> </div>
<div> <div>
<p className="text-xs text-muted-foreground mb-1">Energy</p> <p className="text-xs text-muted-foreground mb-1">Energy</p>
<div className="flex gap-1"> <div role="radiogroup" aria-label="Energy" className="flex gap-1">
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => ( {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => (
<button <button
key={v} key={v}
role="radio"
aria-checked={energy === v}
aria-label={`Energy ${v}`}
onClick={() => handleEnergyChange(v)} onClick={() => handleEnergyChange(v)}
className={cn( className={cn(
"w-6 h-6 rounded text-[10px] font-medium transition-colors", "w-6 h-6 rounded text-[10px] font-medium transition-colors",
+117 -26
View File
@@ -1,11 +1,13 @@
import { useState, useRef, useCallback, useEffect, useMemo } from "react"; import { useState, useRef, useCallback, useEffect, useMemo } from "react";
import { createRoute } from "@tanstack/react-router"; import { createRoute, useNavigate } from "@tanstack/react-router";
import { Route as appRoute } from "../_app"; import { Route as appRoute } from "../_app";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery } from "@/lib/api"; import { api, useApiQuery } from "@/lib/api";
import { useActiveDomainId } from "@/lib/stores/use-active-domain-store";
import { useRealtime } from "@/hooks/use-realtime"; import { useRealtime } from "@/hooks/use-realtime";
import { Search, ZoomIn, ZoomOut, RotateCcw, Filter, X } from "lucide-react"; import { Search, ZoomIn, ZoomOut, RotateCcw, Filter, X } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { LoadingState, EmptyState } from "@/components/state";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
@@ -30,12 +32,26 @@ const ENTITY_COLORS: Record<string, string> = {
domain: "#6366f1", domain: "#6366f1",
}; };
// Graph node types that have a detail page. section/tag/domain nodes appear in
// the graph but have no detail route, so they are intentionally absent.
const NODE_TYPE_ROUTES: Record<string, string> = {
task: "/tasks/$id",
habit: "/habits/$id",
project: "/projects/$id",
note: "/notes/$id",
};
const MAX_NODES = 500; const MAX_NODES = 500;
function GraphPage() { function GraphPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate();
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const graphRef = useRef<any>(undefined); const graphRef = useRef<any>(undefined);
// Simulated node positions, keyed by node id. react-force-graph assigns x/y to
// the node objects it renders during the simulation; the raw API nodes
// (displayNodes) never gain coordinates, so fly-to must look here instead.
const positionsRef = useRef(new Map<string, { x: number; y: number }>());
const [dimensions, setDimensions] = useState({ width: 800, height: 600 }); const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [filterOpen, setFilterOpen] = useState(false); const [filterOpen, setFilterOpen] = useState(false);
@@ -70,9 +86,16 @@ function GraphPage() {
["domains"], ["domains"],
"/domains" "/domains"
); );
const activeDomainId = domainsData?.items?.[0]?.id || ""; // Use the active domain from the store, falling back to the first domain
// while unset. Validate against the fetched list so a persisted id that no
// longer exists doesn't produce a query for a deleted domain.
const storedDomainId = useActiveDomainId();
const activeDomainId =
(storedDomainId && domainsData?.items?.some((d) => d.id === storedDomainId) ? storedDomainId : null) ||
domainsData?.items?.[0]?.id ||
"";
const { data: nodesData } = useApiQuery<{ items: GraphNode[]; totalItems: number }>( const { data: nodesData, isLoading: nodesLoading } = useApiQuery<{ items: GraphNode[]; totalItems: number }>(
["graph", "nodes", activeDomainId], ["graph", "nodes", activeDomainId],
"/graph/nodes?domain=" + activeDomainId, "/graph/nodes?domain=" + activeDomainId,
{ enabled: !!activeDomainId } { enabled: !!activeDomainId }
@@ -154,14 +177,34 @@ function GraphPage() {
}; };
// Search: fly to node // Search: fly to node
const trackNodePosition = useCallback((node: any) => {
if (node && typeof node.x === "number" && typeof node.y === "number") {
positionsRef.current.set(String(node.id), { x: node.x, y: node.y });
}
}, []);
// Snapshot every simulated node's position once the force simulation settles.
const snapshotPositions = useCallback(() => {
const nodes = graphRef.current?.graphData()?.nodes;
if (!nodes) return;
for (const node of nodes) {
trackNodePosition(node);
}
}, [trackNodePosition]);
const handleSearch = useCallback(() => { const handleSearch = useCallback(() => {
if (!search.trim() || !graphRef.current) return; if (!search.trim() || !graphRef.current) return;
const found = displayNodes.find( const found = displayNodes.find(
(n) => n.label.toLowerCase().includes(search.toLowerCase()) (n) => n.label.toLowerCase().includes(search.toLowerCase())
); );
if (found) { if (!found) return;
graphRef.current.centerAt(found.x, found.y, 1000); const pos = positionsRef.current.get(found.id);
if (pos) {
graphRef.current.centerAt(pos.x, pos.y, 1000);
graphRef.current.zoom(3, 1000); graphRef.current.zoom(3, 1000);
} else {
// No coordinates yet (e.g. simulation still warming up) — fit the view instead.
graphRef.current.zoomToFit(1000, 50);
} }
}, [search, displayNodes]); }, [search, displayNodes]);
@@ -175,6 +218,15 @@ function GraphPage() {
setDetailOpen(true); setDetailOpen(true);
}, []); }, []);
// Close the detail panel and navigate to the node's detail page when one
// exists (task/habit/project/note). No-ops for types without a detail route.
const handleOpenEntity = useCallback((node: GraphNode) => {
const to = NODE_TYPE_ROUTES[node.type];
if (!to) return;
setDetailOpen(false);
navigate({ to, params: { id: node.id } });
}, [navigate]);
// Node hover → highlight // Node hover → highlight
const handleNodeHover = useCallback((node: any | null) => { const handleNodeHover = useCallback((node: any | null) => {
setHoveredNode(node as GraphNode | null); setHoveredNode(node as GraphNode | null);
@@ -273,7 +325,7 @@ function GraphPage() {
{/* Graph canvas area */} {/* Graph canvas area */}
<div ref={containerRef} className="flex-1 relative bg-muted/20"> <div ref={containerRef} className="flex-1 relative bg-muted/20">
{/* Toolbar */} {/* Toolbar */}
<div className="absolute top-4 left-4 z-10 flex items-center gap-2"> <div className="absolute top-4 left-4 z-10 flex flex-wrap items-center gap-2">
<div className="relative"> <div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input <Input
@@ -281,7 +333,7 @@ function GraphPage() {
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
onKeyDown={handleSearchKeyDown} onKeyDown={handleSearchKeyDown}
className="pl-8 w-64 bg-background/90 backdrop-blur" className="pl-8 w-40 sm:w-64 bg-background/90 backdrop-blur"
/> />
</div> </div>
<Button variant="outline" size="icon" onClick={() => setFilterOpen(true)} aria-label="Filters"> <Button variant="outline" size="icon" onClick={() => setFilterOpen(true)} aria-label="Filters">
@@ -312,24 +364,35 @@ function GraphPage() {
)} )}
{/* Force graph */} {/* Force graph */}
<ForceGraph2D {activeDomainId && nodesLoading ? (
ref={graphRef} <LoadingState label="Loading graph..." />
graphData={graphData} ) : displayNodes.length === 0 ? (
width={dimensions.width} <EmptyState
height={dimensions.height} title="No graph data yet"
nodeCanvasObject={nodeCanvasObject} description="Create tasks, habits, or projects to see them connected here"
linkCanvasObject={linkCanvasObject} />
linkDirectionalArrowLength={0} ) : (
linkDirectionalArrowRelPos={0.5} <ForceGraph2D
onNodeClick={handleNodeClick} ref={graphRef}
onNodeHover={handleNodeHover} graphData={graphData}
nodeRelSize={6} width={dimensions.width}
d3AlphaDecay={0.02} height={dimensions.height}
d3VelocityDecay={0.3} nodeCanvasObject={nodeCanvasObject}
cooldownTicks={100} linkCanvasObject={linkCanvasObject}
warmupTicks={40} linkDirectionalArrowLength={0}
backgroundColor="transparent" linkDirectionalArrowRelPos={0.5}
/> onNodeClick={handleNodeClick}
onNodeHover={handleNodeHover}
onNodeDrag={trackNodePosition}
onEngineStop={snapshotPositions}
nodeRelSize={6}
d3AlphaDecay={0.02}
d3VelocityDecay={0.3}
cooldownTicks={100}
warmupTicks={40}
backgroundColor="transparent"
/>
)}
</div> </div>
{/* Filter panel */} {/* Filter panel */}
@@ -391,6 +454,19 @@ function GraphPage() {
</div> </div>
<p className="text-sm text-muted-foreground">ID: {selectedNode.id}</p> <p className="text-sm text-muted-foreground">ID: {selectedNode.id}</p>
<Separator /> <Separator />
{NODE_TYPE_ROUTES[selectedNode.type] ? (
<Button
className="w-full"
onClick={() => handleOpenEntity(selectedNode)}
>
Open {selectedNode.type.charAt(0).toUpperCase() + selectedNode.type.slice(1)}
</Button>
) : (
<p className="text-xs text-muted-foreground">
No detail page for {selectedNode.type}
</p>
)}
<Separator />
<h4 className="text-sm font-semibold">Connected nodes</h4> <h4 className="text-sm font-semibold">Connected nodes</h4>
<div className="space-y-1"> <div className="space-y-1">
{displayEdges {displayEdges
@@ -401,7 +477,22 @@ function GraphPage() {
return connected ? ( return connected ? (
<div key={i} className="flex items-center gap-2 text-sm py-1"> <div key={i} className="flex items-center gap-2 text-sm py-1">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: connected.color }} /> <div className="w-2 h-2 rounded-full" style={{ backgroundColor: connected.color }} />
<span className="truncate flex-1">{connected.label}</span> {NODE_TYPE_ROUTES[connected.type] ? (
<button
type="button"
onClick={() => handleOpenEntity(connected)}
className="truncate flex-1 text-left hover:underline"
>
{connected.label}
</button>
) : (
<>
<span className="truncate flex-1">{connected.label}</span>
<span className="shrink-0 text-[10px] text-muted-foreground">
No detail page for {connected.type}
</span>
</>
)}
<Badge variant="outline" className="text-[10px]">{e.type.replace(/_/g, " ")}</Badge> <Badge variant="outline" className="text-[10px]">{e.type.replace(/_/g, " ")}</Badge>
</div> </div>
) : null; ) : null;
+30 -10
View File
@@ -1,10 +1,11 @@
import { useState } from "react"; import { useState } from "react";
import { createRoute } from "@tanstack/react-router"; import { createRoute, useNavigate } from "@tanstack/react-router";
import { Route as appRoute } from "../_app"; import { Route as appRoute } from "../_app";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery, useApiMutation } from "@/lib/api"; import { api, useApiQuery, useApiMutation } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { useRealtime } from "@/hooks/use-realtime"; import { useRealtime } from "@/hooks/use-realtime";
import { Plus, Flame, Trash2, Check, Calendar, TrendingUp } from "lucide-react"; import { Plus, Flame, Trash2, Check, Pencil } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -15,6 +16,7 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { EntityDetailPanel } from "@/components/entities/entity-detail-panel"; import { EntityDetailPanel } from "@/components/entities/entity-detail-panel";
import { LoadingState, EmptyState, ErrorState } from "@/components/state";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Habit, HabitCompletion, PaginatedResponse } from "@/lib/types"; import type { Habit, HabitCompletion, PaginatedResponse } from "@/lib/types";
@@ -58,7 +60,7 @@ function HabitForm({ habit, onClose }: { habit?: Habit; onClose: () => void }) {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<Label htmlFor="freq">Frequency</Label> <Label htmlFor="freq">Frequency</Label>
<Select value={frequency} onValueChange={setFrequency}> <Select value={frequency} onValueChange={(v) => setFrequency(v as "daily" | "weekly" | "custom")}>
<SelectTrigger id="freq"><SelectValue /></SelectTrigger> <SelectTrigger id="freq"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="daily">Daily</SelectItem> <SelectItem value="daily">Daily</SelectItem>
@@ -69,7 +71,7 @@ function HabitForm({ habit, onClose }: { habit?: Habit; onClose: () => void }) {
</div> </div>
<div> <div>
<Label htmlFor="diff">Difficulty</Label> <Label htmlFor="diff">Difficulty</Label>
<Select value={difficulty} onValueChange={setDifficulty}> <Select value={difficulty} onValueChange={(v) => setDifficulty(v as "easy" | "medium" | "hard")}>
<SelectTrigger id="diff"><SelectValue /></SelectTrigger> <SelectTrigger id="diff"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="easy">Easy</SelectItem> <SelectItem value="easy">Easy</SelectItem>
@@ -113,6 +115,7 @@ function MiniGrid({ completions, days = 7 }: { completions: HabitCompletion[]; d
} }
function HabitsPage() { function HabitsPage() {
const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [selectedHabit, setSelectedHabit] = useState<Habit | null>(null); const [selectedHabit, setSelectedHabit] = useState<Habit | null>(null);
@@ -121,9 +124,11 @@ function HabitsPage() {
useRealtime({ enabled: true }); useRealtime({ enabled: true });
const { data: habitsData, isLoading } = useApiQuery<PaginatedResponse<Habit>>( const activeDomainId = useApiDomain();
["habits"],
"/habits?limit=200" const { data: habitsData, isLoading, isError, refetch } = useApiQuery<PaginatedResponse<Habit>>(
["habits", activeDomainId],
"/habits?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "")
); );
const habits = habitsData?.items || []; const habits = habitsData?.items || [];
@@ -138,7 +143,11 @@ function HabitsPage() {
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["habits"] }); setPanelOpen(false); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["habits"] }); setPanelOpen(false); },
}); });
const openHabitDetail = async (habit: Habit) => { const openHabitDetail = (habit: Habit) => {
navigate({ to: "/habits/$id", params: { id: habit.id } });
};
const openHabitPanel = async (habit: Habit) => {
try { try {
const detail = await api.get<Habit>("/habits/" + habit.id); const detail = await api.get<Habit>("/habits/" + habit.id);
setSelectedHabit(detail); setSelectedHabit(detail);
@@ -164,9 +173,11 @@ function HabitsPage() {
</div> </div>
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center py-12 text-muted-foreground">Loading habits...</div> <LoadingState label="Loading habits..." />
) : isError ? (
<ErrorState message="Failed to load habits." onRetry={() => refetch()} />
) : habits.length === 0 ? ( ) : habits.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">No habits yet. Create your first one!</div> <EmptyState icon={Flame} title="No habits yet" description="Create your first one!" />
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{habits.map((habit) => ( {habits.map((habit) => (
@@ -197,6 +208,15 @@ function HabitsPage() {
> >
<Check className="h-4 w-4 mr-1" />Complete <Check className="h-4 w-4 mr-1" />Complete
</Button> </Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => { e.stopPropagation(); openHabitPanel(habit); }}
aria-label={"Edit " + habit.name}
>
<Pencil className="h-4 w-4" />
</Button>
</div> </div>
</div> </div>
</CardContent> </CardContent>
+8 -1
View File
@@ -5,7 +5,8 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { ArrowLeft, Flame, Calendar } from "lucide-react"; import { TagManager } from "@/components/entities/tag-manager";
import { ArrowLeft, Flame, Calendar, Clock } from "lucide-react";
import type { Habit, HabitCompletion } from "@/lib/types"; import type { Habit, HabitCompletion } from "@/lib/types";
import { format, parseISO } from "date-fns"; import { format, parseISO } from "date-fns";
@@ -33,6 +34,10 @@ function HabitDetail() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Created {format(parseISO(habit.createdAt), "MMM d, yyyy HH:mm")}</span>
<span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Updated {format(parseISO(habit.updatedAt), "MMM d, yyyy HH:mm")}</span>
</div>
{habit.description && ( {habit.description && (
<div> <div>
<h3 className="text-sm font-semibold text-muted-foreground mb-1">Description</h3> <h3 className="text-sm font-semibold text-muted-foreground mb-1">Description</h3>
@@ -55,6 +60,8 @@ function HabitDetail() {
</div> </div>
</div> </div>
<Separator /> <Separator />
<TagManager entityType="habit" entityId={habit.id} tags={habit.tags || []} />
<Separator />
<div> <div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Recent Completions</h3> <h3 className="text-sm font-semibold text-muted-foreground mb-2">Recent Completions</h3>
{completions.length === 0 ? ( {completions.length === 0 ? (
+40 -9
View File
@@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery, useApiMutation } from "@/lib/api"; import { api, useApiQuery, useApiMutation } from "@/lib/api";
import { useRealtime } from "@/hooks/use-realtime"; import { useRealtime } from "@/hooks/use-realtime";
import { Plus, Settings2, Trash2, ListTodo, Flame, FileText, FolderKanban, Calendar, Zap, TrendingUp, Target } from "lucide-react"; import { Plus, Settings2, Trash2, ListTodo, Flame, FileText, FolderKanban, Calendar, Zap, TrendingUp, Target } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -30,7 +31,7 @@ const WIDGET_TYPES = [
] as const; ] as const;
function TasksDueWidget() { function TasksDueWidget() {
const { data } = useApiQuery<PaginatedResponse<Task>>(["tasks-due"], "/tasks?limit=10&status=todo,in_progress&sort=dueDate"); const { data } = useApiQuery<PaginatedResponse<Task>>(["tasks-due"], "/tasks?limit=10&status=todo,in_progress&sort=due_date");
const tasks = data?.items || []; const tasks = data?.items || [];
const today = tasks.filter((t) => t.dueDate && isToday(parseISO(t.dueDate))); const today = tasks.filter((t) => t.dueDate && isToday(parseISO(t.dueDate)));
const overdue = tasks.filter((t) => t.dueDate && isPast(parseISO(t.dueDate)) && t.status !== "done"); const overdue = tasks.filter((t) => t.dueDate && isPast(parseISO(t.dueDate)) && t.status !== "done");
@@ -74,7 +75,12 @@ function HabitsTodayWidget() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const completeMutation = useMutation({ const completeMutation = useMutation({
mutationFn: (id: string) => api.post("/habits/" + id + "/complete", {}), mutationFn: (id: string) => api.post("/habits/" + id + "/complete", {}),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["habits-today"] }), onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["habits-today"] });
queryClient.invalidateQueries({ queryKey: ["streaks"] });
toast.success("Habit completed");
},
onError: (err) => toast.error(err.message || "Failed to complete habit"),
}); });
return ( return (
<div className="space-y-1"> <div className="space-y-1">
@@ -197,11 +203,21 @@ function QuickCaptureWidget() {
const [type, setType] = useState<"task" | "note">("task"); const [type, setType] = useState<"task" | "note">("task");
const createTask = useMutation({ const createTask = useMutation({
mutationFn: (title: string) => api.post<Task>("/tasks", { title, status: "todo", priority: "medium" }), mutationFn: (title: string) => api.post<Task>("/tasks", { title, status: "todo", priority: "medium" }),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["tasks-due"] }); setText(""); }, onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tasks-due"] });
setText("");
toast.success("Task added");
},
onError: (err) => toast.error(err.message || "Failed to create task"),
}); });
const createNote = useMutation({ const createNote = useMutation({
mutationFn: (title: string) => api.post<Note>("/notes", { title, content: "" }), mutationFn: (title: string) => api.post<Note>("/notes", { title, content: "" }),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["recent-notes"] }); setText(""); }, onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["recent-notes"] });
setText("");
toast.success("Note added");
},
onError: (err) => toast.error(err.message || "Failed to create note"),
}); });
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -293,7 +309,10 @@ function WidgetCard({ widget, onConfigure, onDelete }: { widget: DashboardWidget
const typeInfo = WIDGET_TYPES.find((t) => t.id === widget.type); const typeInfo = WIDGET_TYPES.find((t) => t.id === widget.type);
const Icon = typeInfo?.icon || Target; const Icon = typeInfo?.icon || Target;
return ( return (
<Card className="h-full flex flex-col group" style={{ gridColumn: "span " + (widget.layout.w || 2), gridRow: "span " + (widget.layout.h || 2) }}> <Card
className="h-full flex flex-col group lg:[grid-column:span_var(--w)] lg:[grid-row:span_var(--h)]"
style={{ "--w": Math.min(widget.layout.w || 2, 4), "--h": widget.layout.h || 2 } as React.CSSProperties}
>
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between gap-2"> <CardHeader className="p-3 pb-0 flex flex-row items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" /> <Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
@@ -397,15 +416,27 @@ function DashboardPage() {
const widgets = widgetsData?.items || []; const widgets = widgetsData?.items || [];
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: (data: any) => api.post<DashboardWidget>("/dashboard/widgets", data), mutationFn: (data: any) => api.post<DashboardWidget>("/dashboard/widgets", data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["dashboard-widgets"] }), onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["dashboard-widgets"] });
toast.success("Widget added");
},
onError: (err) => toast.error(err.message || "Failed to add widget"),
}); });
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => api.patch<DashboardWidget>("/dashboard/widgets/" + id, data), mutationFn: ({ id, data }: { id: string; data: any }) => api.patch<DashboardWidget>("/dashboard/widgets/" + id, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["dashboard-widgets"] }), onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["dashboard-widgets"] });
toast.success("Widget updated");
},
onError: (err) => toast.error(err.message || "Failed to update widget"),
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete("/dashboard/widgets/" + id), mutationFn: (id: string) => api.delete("/dashboard/widgets/" + id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["dashboard-widgets"] }), onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["dashboard-widgets"] });
toast.success("Widget removed");
},
onError: (err) => toast.error(err.message || "Failed to remove widget"),
}); });
const handleAddWidget = (type: string) => { const handleAddWidget = (type: string) => {
const typeInfo = WIDGET_TYPES.find((t) => t.id === type); const typeInfo = WIDGET_TYPES.find((t) => t.id === type);
@@ -431,7 +462,7 @@ function DashboardPage() {
<Button onClick={() => handleAddWidget("tasks_due")}><Plus className="h-4 w-4 mr-2" />Add Default Widgets</Button> <Button onClick={() => handleAddWidget("tasks_due")}><Plus className="h-4 w-4 mr-2" />Add Default Widgets</Button>
</div> </div>
) : ( ) : (
<div className="grid gap-4" style={{ gridTemplateColumns: "repeat(12, 1fr)", gridAutoRows: "minmax(120px, auto)" }}> <div className="grid grid-cols-1 gap-4 auto-rows-[minmax(120px,auto)] sm:grid-cols-2 lg:grid-cols-4">
{widgets.map((w) => ( {widgets.map((w) => (
<WidgetCard key={w.id} widget={w} onConfigure={() => handleConfigure(w)} onDelete={() => handleDelete(w.id)} /> <WidgetCard key={w.id} widget={w} onConfigure={() => handleConfigure(w)} onDelete={() => handleDelete(w.id)} />
))} ))}
+87 -30
View File
@@ -3,6 +3,7 @@ import { createRoute } from "@tanstack/react-router";
import { Route as appRoute } from "../_app"; import { Route as appRoute } from "../_app";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery } from "@/lib/api"; import { api, useApiQuery } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { useRealtime } from "@/hooks/use-realtime"; import { useRealtime } from "@/hooks/use-realtime";
import { Plus, Trash2, Search, Pin, FileText, Link as LinkIcon, History } from "lucide-react"; import { Plus, Trash2, Search, Pin, FileText, Link as LinkIcon, History } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -11,38 +12,92 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Note, PaginatedResponse } from "@/lib/types"; import type { Note, PaginatedResponse } from "@/lib/types";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import Placeholder from "@tiptap/extension-placeholder";
// Simple TipTap-like editor using contentEditable - saves on blur only const AUTOSAVE_DEBOUNCE_MS = 800;
// TipTap-based note editor. Autosaves with a debounce (plus a save-on-blur and a
// flush-on-unmount safety net) and deliberately does NOT stop propagation of
// key/mouse events, so global shortcuts (command palette, etc.) keep working.
const NoteEditor = memo(function NoteEditor({ initialContent, onSave, placeholder = "Start writing..." }: { initialContent: string; onSave: (html: string) => void; placeholder?: string }) { const NoteEditor = memo(function NoteEditor({ initialContent, onSave, placeholder = "Start writing..." }: { initialContent: string; onSave: (html: string) => void; placeholder?: string }) {
const editorRef = useRef<HTMLDivElement>(null); const latestHtmlRef = useRef(initialContent || "");
const [isPlaceholder, setIsPlaceholder] = useState(!initialContent); const dirtyRef = useRef(false);
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const editor = useEditor(
{
extensions: [
StarterKit.configure({ link: false }),
Link.configure({ openOnClick: false }),
Placeholder.configure({ placeholder }),
],
content: initialContent || "",
editorProps: {
attributes: {
class: "focus:outline-none min-h-[300px] p-3",
},
},
},
[placeholder, initialContent]
);
useEffect(() => { useEffect(() => {
if (editorRef.current && !editorRef.current.innerHTML) { if (!editor) return;
editorRef.current.innerHTML = initialContent || "";
}
setIsPlaceholder(!initialContent);
}, []);
const handleBlur = () => { const flushSave = () => {
const html = editorRef.current?.innerHTML || ""; if (saveTimerRef.current) {
onSave(html); clearTimeout(saveTimerRef.current);
}; saveTimerRef.current = null;
}
if (!dirtyRef.current) return;
dirtyRef.current = false;
onSave(latestHtmlRef.current);
};
const handleUpdate = () => {
latestHtmlRef.current = editor.getHTML();
dirtyRef.current = true;
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(flushSave, AUTOSAVE_DEBOUNCE_MS);
};
editor.on("update", handleUpdate);
editor.on("blur", flushSave);
return () => {
editor.off("update", handleUpdate);
editor.off("blur", flushSave);
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
// Flush any unsaved edits on unmount so switching notes doesn't drop typing.
if (dirtyRef.current) {
dirtyRef.current = false;
onSave(latestHtmlRef.current);
}
};
}, [editor, onSave]);
if (!editor) return null;
return ( return (
<div className="relative min-h-[300px]"> <div className="note-editor relative min-h-[300px]">
{isPlaceholder && ( {/* Placeholder needs its ::before styling; the @tailwindcss/typography plugin
<div className="absolute top-0 left-0 text-muted-foreground pointer-events-none p-3 text-sm">{placeholder}</div> is not installed, so this is scoped CSS for the empty-editor state. */}
)} <style>{`
<div .note-editor p.is-editor-empty:first-child::before {
ref={editorRef} content: attr(data-placeholder);
contentEditable color: hsl(var(--muted-foreground));
suppressContentEditableWarning float: left;
className="prose prose-sm dark:prose-invert max-w-none p-3 focus:outline-none min-h-[300px]" height: 0;
onMouseDown={(e) => e.stopPropagation()} pointer-events: none;
onKeyDown={(e) => e.stopPropagation()} }
onBlur={handleBlur} `}</style>
/> <EditorContent editor={editor} />
</div> </div>
); );
}); });
@@ -159,9 +214,11 @@ function NotesPage() {
useRealtime({ enabled: true }); useRealtime({ enabled: true });
const activeDomainId = useApiDomain();
const { data: notesData, isLoading } = useApiQuery<PaginatedResponse<Note>>( const { data: notesData, isLoading } = useApiQuery<PaginatedResponse<Note>>(
["notes", search], ["notes", activeDomainId, search],
"/notes?limit=200" + (search ? "&search=" + encodeURIComponent(search) : "") "/notes?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "") + (search ? "&search=" + encodeURIComponent(search) : "")
); );
const notes = notesData?.items || []; const notes = notesData?.items || [];
@@ -201,9 +258,9 @@ function NotesPage() {
}, [deleteMutation]); }, [deleteMutation]);
return ( return (
<div className="flex h-[calc(100vh-8rem)] -m-4 md:-m-6"> <div className="flex flex-col md:flex-row h-auto min-h-[calc(100vh-8rem)] md:h-[calc(100vh-8rem)] -m-4 md:-m-6">
{/* Left pane - note list */} {/* Left pane - note list */}
<div className="w-72 border-r flex flex-col shrink-0"> <div className="w-full md:w-72 h-64 md:h-auto border-b md:border-b-0 md:border-r flex flex-col shrink-0">
<div className="p-3 border-b"> <div className="p-3 border-b">
<div className="relative"> <div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
@@ -246,7 +303,7 @@ function NotesPage() {
</div> </div>
{/* Right pane - editor (memoized, won't re-render on parent state changes) */} {/* Right pane - editor (memoized, won't re-render on parent state changes) */}
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col min-h-64 md:min-h-0">
{selectedNote ? ( {selectedNote ? (
<NoteEditorPane key={selectedNote.id} note={selectedNote} onDelete={handleDeleteNote} /> <NoteEditorPane key={selectedNote.id} note={selectedNote} onDelete={handleDeleteNote} />
) : ( ) : (
+42 -12
View File
@@ -5,10 +5,21 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { TagManager } from "@/components/entities/tag-manager";
import { ArrowLeft, FileText, Clock } from "lucide-react"; import { ArrowLeft, FileText, Clock } from "lucide-react";
import type { Note } from "@/lib/types"; import type { Note } from "@/lib/types";
import { format, parseISO } from "date-fns"; import { format, parseISO } from "date-fns";
// Note content is Tiptap-generated HTML stored by the API. Lightweight sanitizer
// applied before rendering via dangerouslySetInnerHTML: drops script/style
// blocks, inline event handlers, and javascript: URLs.
const sanitizeNoteHtml = (html: string): string =>
html
.replace(/<script\b[^>]*>[\s\S]*?<\/script\s*>/gi, "")
.replace(/<style\b[^>]*>[\s\S]*?<\/style\s*>/gi, "")
.replace(/\son[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "")
.replace(/(\shref|\ssrc)\s*=\s*(?:"|')\s*javascript:[^"']*(?:"|')/gi, ' $1=""');
function NoteDetail() { function NoteDetail() {
const { id } = useParams({ from: Route.id }); const { id } = useParams({ from: Route.id });
const navigate = useNavigate(); const navigate = useNavigate();
@@ -36,19 +47,38 @@ function NoteDetail() {
<span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Updated {format(parseISO(note.updatedAt), "MMM d, yyyy HH:mm")}</span> <span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Updated {format(parseISO(note.updatedAt), "MMM d, yyyy HH:mm")}</span>
</div> </div>
<Separator /> <Separator />
<div className="prose prose-sm dark:prose-invert max-w-none"> {/* The @tailwindcss/typography plugin isn't installed, so style the
<p className="text-sm whitespace-pre-wrap">{note.content || "No content"}</p> Tiptap output with scoped CSS instead of `prose` classes. */}
<div className="note-detail-content max-w-none">
<style>{`
.note-detail-content { line-height: 1.75; }
.note-detail-content h1 { font-size: 1.75rem; font-weight: 700; line-height: 1.25; margin: 1.5rem 0 0.75rem; }
.note-detail-content h2 { font-size: 1.5rem; font-weight: 700; line-height: 1.3; margin: 1.5rem 0 0.75rem; }
.note-detail-content h3 { font-size: 1.25rem; font-weight: 600; line-height: 1.4; margin: 1.25rem 0 0.5rem; }
.note-detail-content h4 { font-size: 1.125rem; font-weight: 600; line-height: 1.4; margin: 1rem 0 0.5rem; }
.note-detail-content h5, .note-detail-content h6 { font-size: 1rem; font-weight: 600; margin: 1rem 0 0.5rem; }
.note-detail-content p { margin: 0.75rem 0; }
.note-detail-content a { color: hsl(var(--primary)); text-decoration: underline; }
.note-detail-content ul { list-style: disc; padding-left: 1.5rem; margin: 0.75rem 0; }
.note-detail-content ol { list-style: decimal; padding-left: 1.5rem; margin: 0.75rem 0; }
.note-detail-content li { margin: 0.25rem 0; }
.note-detail-content li p { margin: 0; }
.note-detail-content blockquote { border-left: 3px solid hsl(var(--border)); padding-left: 1rem; margin: 1rem 0; color: hsl(var(--muted-foreground)); }
.note-detail-content hr { border: 0; border-top: 1px solid hsl(var(--border)); margin: 1.5rem 0; }
.note-detail-content code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.875em; background: hsl(var(--muted)); padding: 0.125rem 0.375rem; border-radius: 0.25rem; }
.note-detail-content pre { background: hsl(var(--muted)); padding: 1rem; border-radius: 0.5rem; overflow-x: auto; margin: 1rem 0; }
.note-detail-content pre code { background: transparent; padding: 0; font-size: 0.875rem; }
.note-detail-content ul[data-type="taskList"] { list-style: none; padding-left: 0.25rem; }
.note-detail-content ul[data-type="taskList"] li { display: flex; align-items: flex-start; gap: 0.5rem; }
.note-detail-content ul[data-type="taskList"] li p { flex: 1; }
`}</style>
{note.content ? (
<div dangerouslySetInnerHTML={{ __html: sanitizeNoteHtml(note.content) }} />
) : (
<p className="text-sm text-muted-foreground">No content</p>
)}
</div> </div>
{note.tags && note.tags.length > 0 && ( <TagManager entityType="note" entityId={note.id} tags={note.tags || []} />
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Tags</h3>
<div className="flex flex-wrap gap-2">
{note.tags.map((t: any) => (
<Badge key={t.id || t.name} variant="secondary">{t.name || t}</Badge>
))}
</div>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
+32 -10
View File
@@ -3,8 +3,9 @@ import { createRoute, useNavigate } from "@tanstack/react-router";
import { Route as appRoute } from "../_app"; import { Route as appRoute } from "../_app";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery, useApiMutation } from "@/lib/api"; import { api, useApiQuery, useApiMutation } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { useRealtime } from "@/hooks/use-realtime"; import { useRealtime } from "@/hooks/use-realtime";
import { Plus, Trash2, FolderKanban, Users, Calendar, ListTodo, GripVertical } from "lucide-react"; import { Plus, Pencil, Trash2, FolderKanban, Users, Calendar, ListTodo, GripVertical } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -18,6 +19,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { EntityDetailPanel } from "@/components/entities/entity-detail-panel"; import { EntityDetailPanel } from "@/components/entities/entity-detail-panel";
import { LoadingState, EmptyState, ErrorState } from "@/components/state";
import { PROJECT_STATUS, TASK_STATUS } from "@/lib/status-colors";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Project, Section, Task, PaginatedResponse } from "@/lib/types"; import type { Project, Section, Task, PaginatedResponse } from "@/lib/types";
@@ -61,7 +64,7 @@ function ProjectForm({ project, onClose }: { project?: Project; onClose: () => v
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<Label htmlFor="status">Status</Label> <Label htmlFor="status">Status</Label>
<Select value={status} onValueChange={setStatus}> <Select value={status} onValueChange={(v) => setStatus(v as "active" | "paused" | "completed" | "archived")}>
<SelectTrigger id="status"><SelectValue /></SelectTrigger> <SelectTrigger id="status"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="active">Active</SelectItem> <SelectItem value="active">Active</SelectItem>
@@ -100,9 +103,11 @@ function ProjectsPage() {
useRealtime({ enabled: true }); useRealtime({ enabled: true });
const { data: projectsData, isLoading } = useApiQuery<PaginatedResponse<Project>>( const activeDomainId = useApiDomain();
["projects"],
"/projects?limit=200" const { data: projectsData, isLoading, isError, refetch } = useApiQuery<PaginatedResponse<Project>>(
["projects", activeDomainId],
"/projects?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "")
); );
const projects = projectsData?.items || []; const projects = projectsData?.items || [];
@@ -112,7 +117,11 @@ function ProjectsPage() {
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["projects"] }); setPanelOpen(false); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["projects"] }); setPanelOpen(false); },
}); });
const openProjectDetail = async (project: Project) => { const openProjectDetail = (project: Project) => {
navigate({ to: "/projects/$id", params: { id: project.id } });
};
const openProjectPanel = async (project: Project) => {
try { try {
const detail = await api.get<Project>("/projects/" + project.id); const detail = await api.get<Project>("/projects/" + project.id);
setSelectedProject(detail); setSelectedProject(detail);
@@ -138,9 +147,11 @@ function ProjectsPage() {
</div> </div>
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center py-12 text-muted-foreground">Loading projects...</div> <LoadingState label="Loading projects..." />
) : isError ? (
<ErrorState message="Failed to load projects." onRetry={() => refetch()} />
) : projects.length === 0 ? ( ) : projects.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">No projects yet.</div> <EmptyState title="No projects yet" description="Create your first project to get started." />
) : ( ) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{projects.map((project) => ( {projects.map((project) => (
@@ -149,7 +160,18 @@ function ProjectsPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: project.color || "#3b82f6" }} /> <div className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: project.color || "#3b82f6" }} />
<CardTitle className="text-base truncate">{project.name}</CardTitle> <CardTitle className="text-base truncate">{project.name}</CardTitle>
<Badge variant="secondary" className="ml-auto text-[10px]">{project.status}</Badge> <Badge className={cn("ml-auto text-[10px]", PROJECT_STATUS[project.status]?.badge)}>
{PROJECT_STATUS[project.status]?.label ?? project.status}
</Badge>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={(e) => { e.stopPropagation(); openProjectPanel(project); }}
aria-label={"Edit " + project.name}
>
<Pencil className="h-4 w-4" />
</Button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -208,7 +230,7 @@ function ProjectsPage() {
{selectedProject.tasks.map((task: any) => ( {selectedProject.tasks.map((task: any) => (
<div key={task.id} className="flex items-center justify-between text-sm py-1.5 border-b last:border-0"> <div key={task.id} className="flex items-center justify-between text-sm py-1.5 border-b last:border-0">
<span className="truncate">{task.title}</span> <span className="truncate">{task.title}</span>
<Badge variant="secondary" className="text-[10px] shrink-0">{task.status}</Badge> <Badge className={cn("text-[10px] shrink-0", TASK_STATUS[task.status]?.badge)}>{TASK_STATUS[task.status]?.label ?? task.status}</Badge>
</div> </div>
))} ))}
</div> </div>
+8 -11
View File
@@ -5,18 +5,11 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { ArrowLeft, Calendar, ListTodo, Activity } from "lucide-react"; import { ArrowLeft, Calendar, Clock, ListTodo, Activity } from "lucide-react";
import type { Project } from "@/lib/types"; import type { Project } from "@/lib/types";
import { format, parseISO } from "date-fns"; import { format, parseISO } from "date-fns";
import { PROJECT_STATUS } from "@/lib/status-colors";
const STATUS_COLORS: Record<string, string> = {
active: "bg-green-500",
paused: "bg-amber-500",
completed: "bg-blue-500",
archived: "bg-slate-500",
};
function ProjectDetail() { function ProjectDetail() {
const { id } = useParams({ from: Route.id }); const { id } = useParams({ from: Route.id });
@@ -27,7 +20,7 @@ function ProjectDetail() {
if (!project) return <div className="p-8 text-center text-muted-foreground">Project not found</div>; if (!project) return <div className="p-8 text-center text-muted-foreground">Project not found</div>;
return ( return (
<div className="max-w-4xl mx-auto p-6 space-y-6"> <div className="max-w-2xl mx-auto p-6 space-y-6">
<Button variant="ghost" onClick={() => navigate({ to: "/projects" })} className="w-fit"> <Button variant="ghost" onClick={() => navigate({ to: "/projects" })} className="w-fit">
<ArrowLeft className="h-4 w-4 mr-2" /> Back to Projects <ArrowLeft className="h-4 w-4 mr-2" /> Back to Projects
</Button> </Button>
@@ -36,10 +29,14 @@ function ProjectDetail() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: project.color || "#3b82f6" }} /> <div className="w-4 h-4 rounded-full" style={{ backgroundColor: project.color || "#3b82f6" }} />
<CardTitle className="text-2xl">{project.name}</CardTitle> <CardTitle className="text-2xl">{project.name}</CardTitle>
<Badge className={STATUS_COLORS[project.status] || "bg-slate-500"}>{project.status}</Badge> <Badge className={PROJECT_STATUS[project.status]?.badge}>{PROJECT_STATUS[project.status]?.label ?? project.status}</Badge>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Created {format(parseISO(project.createdAt), "MMM d, yyyy HH:mm")}</span>
<span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Updated {format(parseISO(project.updatedAt), "MMM d, yyyy HH:mm")}</span>
</div>
{project.description && ( {project.description && (
<div> <div>
<h3 className="text-sm font-semibold text-muted-foreground mb-1">Description</h3> <h3 className="text-sm font-semibold text-muted-foreground mb-1">Description</h3>
+13 -1
View File
@@ -20,6 +20,18 @@ const SEARCH_TYPES = [
{ id: "domain", label: "Domains", color: "bg-indigo-500" }, { id: "domain", label: "Domains", color: "bg-indigo-500" },
]; ];
// Sanitize snippet HTML before it hits dangerouslySetInnerHTML. The API's
// ts_headline output is safe text with matches wrapped in <mark>...</mark>.
// Allow ONLY <mark> open/close tags (and only without event handler / href /
// src attributes) so no other element, script, or attribute can be injected.
const sanitizeSnippet = (html: string) =>
html
.replace(/<script\b[^>]*>[\s\S]*?<\/script\s*>/gi, "")
.replace(/<\/?([a-zA-Z][a-zA-Z0-9-]*)(\s[^<>]*)?>/g, (full, tag) => {
if (tag.toLowerCase() === "mark" && !/<[^>]*(?:on\w+=|href=|src=)/i.test(full)) return full;
return "";
});
function SearchPage() { function SearchPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
@@ -161,7 +173,7 @@ function SearchPage() {
{result.snippet && ( {result.snippet && (
<p <p
className="text-xs text-muted-foreground mt-0.5 line-clamp-2" className="text-xs text-muted-foreground mt-0.5 line-clamp-2"
dangerouslySetInnerHTML={{ __html: result.snippet }} dangerouslySetInnerHTML={{ __html: sanitizeSnippet(result.snippet) }}
/> />
)} )}
</div> </div>
+246 -35
View File
@@ -4,6 +4,7 @@ import { Route as appRoute } from "../_app";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery, useApiMutation } from "@/lib/api"; import { api, useApiQuery, useApiMutation } from "@/lib/api";
import { Plus, Trash2, Pencil, Palette, Sun, Moon, Monitor, Type, Maximize, Sidebar, Eye, Globe, Tag, List, Key, Bot, Webhook, Upload, Download, AlertCircle, Check, X, RefreshCw, TestTube } from "lucide-react"; import { Plus, Trash2, Pencil, Palette, Sun, Moon, Monitor, Type, Maximize, Sidebar, Eye, Globe, Tag, List, Key, Bot, Webhook, Upload, Download, AlertCircle, Check, X, RefreshCw, TestTube } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
@@ -13,7 +14,6 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, Dialog
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
@@ -62,8 +62,21 @@ function AppearanceTab() {
const [reducedMotion, setReducedMotion] = useState(localStorage.getItem("reduced-motion") === "true"); const [reducedMotion, setReducedMotion] = useState(localStorage.getItem("reduced-motion") === "true");
useEffect(() => { localStorage.setItem("font-size", fontSize); document.documentElement.style.fontSize = fontSize === "large" ? "18px" : fontSize === "small" ? "13px" : "16px"; }, [fontSize]); useEffect(() => { localStorage.setItem("font-size", fontSize); document.documentElement.style.fontSize = fontSize === "large" ? "18px" : fontSize === "small" ? "13px" : "16px"; }, [fontSize]);
useEffect(() => { localStorage.setItem("density", density); }, [density]); // Density actually changes spacing now: toggle the density-* classes on <html>
useEffect(() => { localStorage.setItem("sidebar-position", sidebarPos); }, [sidebarPos]); // (see the density utilities + --density-scale in index.css) and persist.
useEffect(() => {
localStorage.setItem("density", density);
const root = document.documentElement;
root.classList.remove("density-compact", "density-spacious");
if (density === "compact") root.classList.add("density-compact");
if (density === "spacious") root.classList.add("density-spacious");
}, [density]);
// sidebarPos actually repositions the sidebar now: persist it and tell the
// app shell (sidebar.tsx) to re-read it without a reload.
useEffect(() => {
localStorage.setItem("sidebar-position", sidebarPos);
window.dispatchEvent(new CustomEvent("sidebar-position-change", { detail: sidebarPos }));
}, [sidebarPos]);
useEffect(() => { localStorage.setItem("reduced-motion", String(reducedMotion)); document.documentElement.classList.toggle("reduce-motion", reducedMotion); }, [reducedMotion]); useEffect(() => { localStorage.setItem("reduced-motion", String(reducedMotion)); document.documentElement.classList.toggle("reduce-motion", reducedMotion); }, [reducedMotion]);
return ( return (
@@ -420,16 +433,63 @@ function AgentsTab() {
const agents = data?.items || []; const agents = data?.items || [];
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({ name: "", description: "", permissionTier: "read_only" }); const [form, setForm] = useState({ name: "", description: "", permissionTier: "read_only" });
const [editAgent, setEditAgent] = useState<Agent | null>(null);
const [editForm, setEditForm] = useState({
name: "",
description: "",
status: "active",
permissionTier: "read_only",
customPermissions: "",
});
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: (d: any) => api.post<Agent>("/agents", d), mutationFn: (d: any) => api.post<Agent>("/agents", d),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["agents"] }); setCreateOpen(false); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["agents"] }); setCreateOpen(false); },
}); });
const updateMutation = useMutation({
mutationFn: ({ id, data: d }: { id: string; data: any }) => api.patch<Agent>("/agents/" + id, d),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["agents"] }); setEditAgent(null); },
});
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete("/agents/" + id), mutationFn: (id: string) => api.delete("/agents/" + id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["agents"] }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["agents"] }),
}); });
const openEdit = (a: Agent) => {
setEditAgent(a);
setEditForm({
name: a.name,
description: a.description || "",
status: a.status,
permissionTier: a.permissionTier,
customPermissions: (a.customPermissions || []).join(", "),
});
};
const handleSaveEdit = () => {
if (!editAgent) return;
const data: any = {
name: editForm.name.trim(),
description: editForm.description || null,
status: editForm.status,
permissionTier: editForm.permissionTier,
};
// customPermissions only apply to the "custom" tier; clear them otherwise so
// a downgrade doesn't leave stale permissions in the database.
data.customPermissions = editForm.permissionTier === "custom"
? editForm.customPermissions.split(",").map((s) => s.trim()).filter(Boolean)
: [];
updateMutation.mutate({ id: editAgent.id, data });
};
const permissionLabels: Record<string, string> = {
full_access: "Full Access",
read_only: "Read Only",
content_creator: "Content Creator",
task_manager: "Task Manager",
custom: "Custom",
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -444,11 +504,9 @@ function AgentsTab() {
<div><Label>Permission Tier</Label><Select value={form.permissionTier} onValueChange={(v) => setForm({ ...form, permissionTier: v })}> <div><Label>Permission Tier</Label><Select value={form.permissionTier} onValueChange={(v) => setForm({ ...form, permissionTier: v })}>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="full_access">Full Access</SelectItem> {Object.entries(permissionLabels).map(([value, label]) => (
<SelectItem value="read_only">Read Only</SelectItem> <SelectItem key={value} value={value}>{label}</SelectItem>
<SelectItem value="content_creator">Content Creator</SelectItem> ))}
<SelectItem value="task_manager">Task Manager</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent> </SelectContent>
</Select></div> </Select></div>
<Button onClick={() => createMutation.mutate(form)} disabled={!form.name.trim() || createMutation.isPending}>Create</Button> <Button onClick={() => createMutation.mutate(form)} disabled={!form.name.trim() || createMutation.isPending}>Create</Button>
@@ -459,20 +517,73 @@ function AgentsTab() {
<div className="space-y-2"> <div className="space-y-2">
{agents.map((a) => ( {agents.map((a) => (
<div key={a.id} className="flex items-center justify-between p-3 rounded-lg border"> <div key={a.id} className="flex items-center justify-between p-3 rounded-lg border">
<div> <div className="min-w-0">
<p className="font-medium">{a.name}</p> <p className="font-medium truncate">{a.name}</p>
<p className="text-xs text-muted-foreground">{a.permissionTier.replace(/_/g, " ")} &middot; {a.status}</p> {a.description && <p className="text-xs text-muted-foreground truncate">{a.description}</p>}
<p className="text-xs text-muted-foreground">
{permissionLabels[a.permissionTier] || a.permissionTier} &middot; {a.status}
</p>
{a.permissionTier === "custom" && a.customPermissions.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{a.customPermissions.map((p) => <Badge key={p} variant="secondary" className="text-[10px]">{p}</Badge>)}
</div>
)}
</div>
<div className="flex gap-1 shrink-0">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEdit(a)} aria-label={"Edit " + a.name}><Pencil className="h-4 w-4" /></Button>
<AlertDialog>
<AlertDialogTrigger asChild><Button variant="ghost" size="icon" className="h-8 w-8 text-destructive"><Trash2 className="h-4 w-4" /></Button></AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader><AlertDialogTitle>Delete Agent</AlertDialogTitle><AlertDialogDescription>Are you sure?</AlertDialogDescription></AlertDialogHeader>
<AlertDialogFooter><AlertDialogCancel>Cancel</AlertDialogCancel><AlertDialogAction onClick={() => deleteMutation.mutate(a.id)} className="bg-destructive">Delete</AlertDialogAction></AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
<AlertDialog>
<AlertDialogTrigger asChild><Button variant="ghost" size="icon" className="text-destructive"><Trash2 className="h-4 w-4" /></Button></AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader><AlertDialogTitle>Delete Agent</AlertDialogTitle><AlertDialogDescription>Are you sure?</AlertDialogDescription></AlertDialogHeader>
<AlertDialogFooter><AlertDialogCancel>Cancel</AlertDialogCancel><AlertDialogAction onClick={() => deleteMutation.mutate(a.id)} className="bg-destructive">Delete</AlertDialogAction></AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
))} ))}
</div> </div>
{editAgent && (
<Dialog open={!!editAgent} onOpenChange={(o) => { if (!o) setEditAgent(null); }}>
<DialogContent>
<DialogHeader><DialogTitle>Edit Agent</DialogTitle></DialogHeader>
<div className="space-y-4">
<div><Label>Name</Label><Input value={editForm.name} onChange={(e) => setEditForm({ ...editForm, name: e.target.value })} /></div>
<div><Label>Description</Label><Textarea value={editForm.description} onChange={(e) => setEditForm({ ...editForm, description: e.target.value })} /></div>
<div><Label>Status</Label><Select value={editForm.status} onValueChange={(v) => setEditForm({ ...editForm, status: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="disabled">Disabled</SelectItem>
</SelectContent>
</Select></div>
<div><Label>Permission Tier</Label><Select value={editForm.permissionTier} onValueChange={(v) => setEditForm({ ...editForm, permissionTier: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{Object.entries(permissionLabels).map(([value, label]) => (
<SelectItem key={value} value={value}>{label}</SelectItem>
))}
</SelectContent>
</Select></div>
{editForm.permissionTier === "custom" && (
<div><Label>Custom Permissions (comma-separated)</Label><Input value={editForm.customPermissions} onChange={(e) => setEditForm({ ...editForm, customPermissions: e.target.value })} placeholder="tasks.write, notes.read, ..." /></div>
)}
{editAgent.apiKey && (
<div>
<Label>API Key</Label>
<div className="flex items-center gap-2">
<Input value={editAgent.apiKey} readOnly className="font-mono text-xs" aria-label="Agent API key" />
<Button variant="outline" size="sm" className="h-9 shrink-0" onClick={() => { navigator.clipboard.writeText(editAgent.apiKey as string); toast.success("API key copied"); }}>
Copy
</Button>
</div>
<p className="text-xs text-muted-foreground mt-1">Shown only here — store it securely before rotating.</p>
</div>
)}
<Button onClick={handleSaveEdit} disabled={!editForm.name.trim() || updateMutation.isPending}>Save</Button>
</div>
</DialogContent>
</Dialog>
)}
</div> </div>
); );
} }
@@ -487,7 +598,7 @@ function WebhooksTab() {
const [form, setForm] = useState({ name: "", url: "", events: "task.created,note.created" }); const [form, setForm] = useState({ name: "", url: "", events: "task.created,note.created" });
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: (d: any) => api.post<Webhook>("/webhooks", d), mutationFn: (d: any) => api.post<WebhookType>("/webhooks", d),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["webhooks"] }); setCreateOpen(false); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["webhooks"] }); setCreateOpen(false); },
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutation({
@@ -546,6 +657,46 @@ function WebhooksTab() {
// ─── Import & Export Tab ───────────────────────────────────────────────── // ─── Import & Export Tab ─────────────────────────────────────────────────
// CSV export helpers. The export API always returns JSON; when the user picks
// CSV we convert each collection client-side. Nested values (tags arrays,
// customFields objects, etc.) are JSON-stringified into a single cell.
/** Escape a single CSV field per RFC 4180: wrap in quotes when needed, double inner quotes. */
function csvEscape(value: string): string {
if (/[",\n\r]/.test(value)) {
return '"' + value.replace(/"/g, '""') + '"';
}
return value;
}
/** Render one cell: null/undefined → empty, scalars pass through, objects/arrays get JSON-stringified. */
function csvCell(value: unknown): string {
if (value === null || value === undefined) return "";
if (typeof value === "string") return csvEscape(value);
if (typeof value === "number" || typeof value === "boolean") return String(value);
return csvEscape(JSON.stringify(value));
}
/** Build a CSV document (header + one row per object) from an array of flat rows. */
function rowsToCSV(rows: Record<string, unknown>[]): string {
if (rows.length === 0) return "";
const columns = [...new Set(rows.flatMap((r) => Object.keys(r)))];
const header = columns.map(csvEscape).join(",");
const body = rows.map((r) => columns.map((c) => csvCell(r[c])).join(","));
return [header, ...body].join("\r\n") + "\r\n";
}
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
function ImportExportTab() { function ImportExportTab() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [importData, setImportData] = useState(""); const [importData, setImportData] = useState("");
@@ -554,22 +705,76 @@ function ImportExportTab() {
const [exportCollections, setExportCollections] = useState<string[]>(["tasks", "habits", "projects", "notes"]); const [exportCollections, setExportCollections] = useState<string[]>(["tasks", "habits", "projects", "notes"]);
const importMutation = useMutation({ const importMutation = useMutation({
mutationFn: (data: any) => api.post("/import", data), mutationFn: (data: any) => api.post<any>("/import", data),
onSuccess: (res) => { setImportResult(res); queryClient.invalidateQueries(); }, onSuccess: (res) => {
setImportResult(res);
queryClient.invalidateQueries();
if (res.success) {
toast.success("Imported " + res.imported + " items");
} else {
toast.error("Imported " + (res.imported ?? 0) + " items, " + (res.failed ?? 0) + " failed");
}
},
onError: (err) => {
const message = err.message || "Import failed";
setImportResult({ success: false, error: message });
toast.error(message);
},
}); });
const handleImport = () => {
let parsed: any;
try {
parsed = JSON.parse(importData);
} catch {
const message = "Invalid JSON in import data";
setImportResult({ success: false, error: message });
toast.error(message);
return;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
const message = "Invalid format: expected an object with a version field";
setImportResult({ success: false, error: message });
toast.error(message);
return;
}
if (!parsed.version) {
const message = "Invalid format: missing version";
setImportResult({ success: false, error: message });
toast.error(message);
return;
}
importMutation.mutate(parsed);
};
const handleExport = async () => { const handleExport = async () => {
try { try {
const data = await api.post<any>("/export", { collections: exportCollections }); const data = await api.post<any>("/export", { collections: exportCollections });
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob); if (exportFormat === "csv") {
const a = document.createElement("a"); // One CSV file per selected collection; empty collections are skipped.
a.href = url; let exported = 0;
a.download = "project-e-export.json"; for (const collection of exportCollections) {
a.click(); const rows = Array.isArray(data[collection]) ? data[collection] : [];
URL.revokeObjectURL(url); if (rows.length === 0) continue;
const csv = rowsToCSV(rows);
downloadBlob(new Blob([csv], { type: "text/csv;charset=utf-8" }), "project-e-export-" + collection + ".csv");
exported++;
}
if (exported === 0) {
toast.error("No data to export for the selected collections");
} else {
toast.success("Exported " + exported + " CSV file" + (exported === 1 ? "" : "s"));
}
return;
}
// JSON export — unchanged.
downloadBlob(new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }), "project-e-export.json");
} catch (e) { } catch (e) {
const message = e instanceof Error ? e.message : "Export failed";
console.error("Export failed", e); console.error("Export failed", e);
toast.error(message);
} }
}; };
@@ -583,12 +788,16 @@ function ImportExportTab() {
<h3 className="text-lg font-semibold mb-3">Import</h3> <h3 className="text-lg font-semibold mb-3">Import</h3>
<p className="text-sm text-muted-foreground mb-3">Paste JSON data to import. Format: {"{"} "version": "1.0", "tasks": [...], "habits": [...], "projects": [...], "notes": [...] {"}"}</p> <p className="text-sm text-muted-foreground mb-3">Paste JSON data to import. Format: {"{"} "version": "1.0", "tasks": [...], "habits": [...], "projects": [...], "notes": [...] {"}"}</p>
<Textarea value={importData} onChange={(e) => setImportData(e.target.value)} placeholder='{"version": "1.0", "tasks": [...]}' rows={6} className="font-mono text-sm" /> <Textarea value={importData} onChange={(e) => setImportData(e.target.value)} placeholder='{"version": "1.0", "tasks": [...]}' rows={6} className="font-mono text-sm" />
<Button className="mt-2" onClick={() => { try { importMutation.mutate(JSON.parse(importData)); } catch { setImportResult({ success: false, error: "Invalid JSON" }); } }} disabled={!importData.trim() || importMutation.isPending}> <Button className="mt-2" onClick={handleImport} disabled={!importData.trim() || importMutation.isPending}>
<Upload className="h-4 w-4 mr-2" />Import <Upload className="h-4 w-4 mr-2" />Import
</Button> </Button>
{importResult && ( {importResult && (
<div className={cn("mt-3 p-3 rounded-lg text-sm", importResult.success ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600")}> <div className={cn("mt-3 p-3 rounded-lg text-sm", importResult.success ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600")}>
{importResult.success ? "Imported " + importResult.imported + " items" : "Import failed: " + (importResult.error || "Unknown error")} {importResult.success
? "Imported " + importResult.imported + " items"
: importResult.error
? "Import failed: " + importResult.error
: "Imported " + (importResult.imported ?? 0) + " items, " + (importResult.failed ?? 0) + " failed"}
</div> </div>
)} )}
</div> </div>
@@ -691,9 +900,9 @@ function SettingsPage() {
const [activeTab, setActiveTab] = useState("appearance"); const [activeTab, setActiveTab] = useState("appearance");
return ( return (
<div className="flex gap-6 h-[calc(100vh-5rem)]"> <div className="flex flex-col md:flex-row gap-2 md:gap-6 h-auto md:h-[calc(100vh-5rem)]">
{/* Sidebar tabs */} {/* Tab bar - horizontal scrollable on mobile, vertical sidebar on md+ */}
<div className="w-56 shrink-0 space-y-1"> <div className="flex md:flex-col gap-1 md:gap-0 overflow-x-auto md:overflow-visible pb-1 md:pb-0 md:w-56 md:shrink-0 md:space-y-1 shrink-0">
{SETTINGS_TABS.map((tab) => { {SETTINGS_TABS.map((tab) => {
const Icon = tab.icon; const Icon = tab.icon;
return ( return (
@@ -701,7 +910,8 @@ function SettingsPage() {
key={tab.id} key={tab.id}
onClick={() => setActiveTab(tab.id)} onClick={() => setActiveTab(tab.id)}
className={cn( className={cn(
"w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors text-left", "flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors text-left whitespace-nowrap shrink-0",
"md:w-full",
activeTab === tab.id ? "bg-primary/10 text-primary" : "text-muted-foreground hover:text-foreground hover:bg-muted/50" activeTab === tab.id ? "bg-primary/10 text-primary" : "text-muted-foreground hover:text-foreground hover:bg-muted/50"
)} )}
> >
@@ -711,7 +921,8 @@ function SettingsPage() {
); );
})} })}
</div> </div>
<Separator orientation="vertical" /> <Separator className="md:hidden" />
<Separator orientation="vertical" className="hidden md:block" />
{/* Content */} {/* Content */}
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
<ScrollArea className="h-full pr-4"> <ScrollArea className="h-full pr-4">
+140 -32
View File
@@ -3,11 +3,12 @@ import { createRoute, useNavigate } from "@tanstack/react-router";
import { Route as appRoute } from "../_app"; import { Route as appRoute } from "../_app";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery, useApiMutation } from "@/lib/api"; import { api, useApiQuery, useApiMutation } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { useRealtime } from "@/hooks/use-realtime"; import { useRealtime } from "@/hooks/use-realtime";
import { DndContext, DragOverlay, closestCorners, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent, type DragStartEvent } from "@dnd-kit/core"; import { DndContext, DragOverlay, closestCorners, KeyboardSensor, PointerSensor, useSensor, useSensors, useDroppable, type DragEndEvent, type DragStartEvent } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable"; import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { Plus, GripVertical, Trash2, Calendar, Clock, ListTodo, Layout as LayoutIcon, Search, Filter, MoreHorizontal } from "lucide-react"; import { Plus, GripVertical, Pencil, Trash2, Calendar, Clock, ListTodo, Layout as LayoutIcon, Search, Filter, MoreHorizontal } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -23,24 +24,20 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
import { EntityDetailPanel } from "@/components/entities/entity-detail-panel"; import { EntityDetailPanel } from "@/components/entities/entity-detail-panel";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { LoadingState, EmptyState, ErrorState } from "@/components/state";
import { CustomFieldInputs } from "@/components/custom-fields/custom-field-inputs";
import { TASK_STATUS, PRIORITY } from "@/lib/status-colors";
import type { Task, PaginatedResponse } from "@/lib/types"; import type { Task, PaginatedResponse } from "@/lib/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const STATUS_COLUMNS = [ const STATUS_COLUMNS = [
{ id: "todo", label: "Todo", color: "bg-slate-500" }, { id: "todo", label: "Todo" },
{ id: "in_progress", label: "In Progress", color: "bg-blue-500" }, { id: "in_progress", label: "In Progress" },
{ id: "done", label: "Done", color: "bg-green-500" }, { id: "done", label: "Done" },
{ id: "cancelled", label: "Cancelled", color: "bg-red-500" }, { id: "cancelled", label: "Cancelled" },
]; ];
const PRIORITY_COLORS: Record<string, string> = { function SortableTaskCard({ task, onClick, onEdit }: { task: Task; onClick: () => void; onEdit?: () => void }) {
urgent: "text-red-500 bg-red-500/10",
high: "text-orange-500 bg-orange-500/10",
medium: "text-blue-500 bg-blue-500/10",
low: "text-slate-500 bg-slate-500/10",
};
function SortableTaskCard({ task, onClick }: { task: Task; onClick: () => void }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id }); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id });
const style = { const style = {
@@ -64,8 +61,8 @@ function SortableTaskCard({ task, onClick }: { task: Task; onClick: () => void }
{new Date(task.dueDate).toLocaleDateString()} {new Date(task.dueDate).toLocaleDateString()}
</Badge> </Badge>
)} )}
<Badge variant="secondary" className={cn("text-[10px]", PRIORITY_COLORS[task.priority])}> <Badge variant="secondary" className={cn("text-[10px]", PRIORITY[task.priority]?.badge)}>
{task.priority} {PRIORITY[task.priority]?.label ?? task.priority}
</Badge> </Badge>
{task.tags?.slice(0, 2).map((tag) => ( {task.tags?.slice(0, 2).map((tag) => (
<Badge key={tag.id} variant="outline" className="text-[10px]" style={{ borderColor: tag.color || undefined }}> <Badge key={tag.id} variant="outline" className="text-[10px]" style={{ borderColor: tag.color || undefined }}>
@@ -74,6 +71,17 @@ function SortableTaskCard({ task, onClick }: { task: Task; onClick: () => void }
))} ))}
</div> </div>
</div> </div>
{onEdit && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 -mt-1 -mr-1 shrink-0"
onClick={(e) => { e.stopPropagation(); onEdit(); }}
aria-label={"Edit " + task.title}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -81,6 +89,15 @@ function SortableTaskCard({ task, onClick }: { task: Task; onClick: () => void }
); );
} }
function ColumnDroppable({ id, className, children }: { id: string; className?: string; children: React.ReactNode }) {
const { setNodeRef, isOver } = useDroppable({ id });
return (
<div ref={setNodeRef} className={cn(className, isOver && "ring-2 ring-primary/40 bg-primary/10")}>
{children}
</div>
);
}
function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) { function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [title, setTitle] = useState(task?.title || ""); const [title, setTitle] = useState(task?.title || "");
@@ -88,6 +105,7 @@ function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
const [status, setStatus] = useState(task?.status || "todo"); const [status, setStatus] = useState(task?.status || "todo");
const [priority, setPriority] = useState(task?.priority || "medium"); const [priority, setPriority] = useState(task?.priority || "medium");
const [dueDate, setDueDate] = useState(task?.dueDate ? task.dueDate.slice(0, 10) : ""); const [dueDate, setDueDate] = useState(task?.dueDate ? task.dueDate.slice(0, 10) : "");
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>(() => ({ ...(task?.customFields ?? {}) }));
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: (data: any) => api.post<Task>("/tasks", data), mutationFn: (data: any) => api.post<Task>("/tasks", data),
@@ -110,6 +128,8 @@ function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
if (!title.trim()) return; if (!title.trim()) return;
const data: any = { title: title.trim(), description: description || null, status, priority }; const data: any = { title: title.trim(), description: description || null, status, priority };
if (dueDate) data.dueDate = new Date(dueDate).toISOString(); if (dueDate) data.dueDate = new Date(dueDate).toISOString();
const customFields = { ...customFieldValues };
if (Object.keys(customFields).length > 0) data.customFields = customFields;
if (task) { if (task) {
updateMutation.mutate(data); updateMutation.mutate(data);
} else { } else {
@@ -130,7 +150,7 @@ function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<Label htmlFor="status">Status</Label> <Label htmlFor="status">Status</Label>
<Select value={status} onValueChange={setStatus}> <Select value={status} onValueChange={(v) => setStatus(v as "todo" | "in_progress" | "done" | "cancelled")}>
<SelectTrigger id="status"><SelectValue /></SelectTrigger> <SelectTrigger id="status"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="todo">Todo</SelectItem> <SelectItem value="todo">Todo</SelectItem>
@@ -142,7 +162,7 @@ function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
</div> </div>
<div> <div>
<Label htmlFor="priority">Priority</Label> <Label htmlFor="priority">Priority</Label>
<Select value={priority} onValueChange={setPriority}> <Select value={priority} onValueChange={(v) => setPriority(v as "low" | "medium" | "high" | "urgent")}>
<SelectTrigger id="priority"><SelectValue /></SelectTrigger> <SelectTrigger id="priority"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="low">Low</SelectItem> <SelectItem value="low">Low</SelectItem>
@@ -157,6 +177,7 @@ function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
<Label htmlFor="dueDate">Due Date</Label> <Label htmlFor="dueDate">Due Date</Label>
<Input id="dueDate" type="date" value={dueDate} onChange={(e) => setDueDate(e.target.value)} /> <Input id="dueDate" type="date" value={dueDate} onChange={(e) => setDueDate(e.target.value)} />
</div> </div>
<CustomFieldInputs entityType="tasks" values={customFieldValues} onChange={setCustomFieldValues} />
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button> <Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}> <Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
@@ -180,9 +201,11 @@ function TasksPage() {
useRealtime({ enabled: true }); useRealtime({ enabled: true });
const { data: tasksData, isLoading } = useApiQuery<PaginatedResponse<Task>>( const activeDomainId = useApiDomain();
["tasks", search, statusFilter],
"/tasks?" + new URLSearchParams({ limit: "200", ...(search ? { search } : {}), ...(statusFilter ? { status: statusFilter } : {}) }).toString() const { data: tasksData, isLoading, isError, refetch } = useApiQuery<PaginatedResponse<Task>>(
["tasks", activeDomainId, search, statusFilter],
"/tasks?" + new URLSearchParams({ limit: "200", ...(activeDomainId ? { domain: activeDomainId } : {}), ...(search ? { search } : {}), ...(statusFilter ? { status: statusFilter } : {}) }).toString()
); );
const tasks = tasksData?.items || []; const tasks = tasksData?.items || [];
@@ -193,6 +216,20 @@ function TasksPage() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] }); queryClient.invalidateQueries({ queryKey: ["tasks"] });
}, },
onError: () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
});
const reorderMutation = useMutation({
mutationFn: ({ orderedIds }: { orderedIds: string[] }) =>
api.post("/tasks/reorder", { orderedIds }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
onError: () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutation({
@@ -218,14 +255,78 @@ function TasksPage() {
if (!over) return; if (!over) return;
const taskId = active.id as string; const taskId = active.id as string;
const targetColumn = over.id as string; const overId = over.id as string;
if (STATUS_COLUMNS.some((c) => c.id === targetColumn)) { const draggedTask = tasks.find((t) => t.id === taskId);
if (!draggedTask) return;
// Tasks of a column in persisted order
const columnTasks = (status: string) =>
tasks
.filter((t) => t.status === status)
.sort((a, b) => a.order - b.order);
// Decide the target column and insertion index:
// - over a column id => drop at the end of that column (handles empty columns)
// - over a task id => drop at that task's position within its column
let targetColumn: string;
let insertIndex: number;
if (STATUS_COLUMNS.some((c) => c.id === overId)) {
targetColumn = overId;
insertIndex = -1;
} else {
const overTask = tasks.find((t) => t.id === overId);
if (!overTask) return;
targetColumn = overTask.status;
const overIndex = columnTasks(targetColumn).findIndex((t) => t.id === overId);
insertIndex = overIndex === -1 ? -1 : overIndex;
}
// Build the new ordered id list for the target column
const targetIds = columnTasks(targetColumn)
.map((t) => t.id)
.filter((id) => id !== taskId);
if (insertIndex === -1) {
targetIds.push(taskId);
} else {
targetIds.splice(Math.min(insertIndex, targetIds.length), 0, taskId);
}
// No-op when the task is already in that exact spot
const currentIds = columnTasks(targetColumn).map((t) => t.id);
const unchanged =
currentIds.length === targetIds.length &&
currentIds.every((id, i) => id === targetIds[i]);
if (unchanged) return;
// Optimistic local update so the board reorders immediately
const statusChanged = draggedTask.status !== targetColumn;
const orderById = new Map(targetIds.map((id, i) => [id, i]));
queryClient.setQueryData<PaginatedResponse<Task>>(["tasks", activeDomainId, search, statusFilter], (old) => {
if (!old) return old;
return {
...old,
items: old.items.map((t) => {
if (t.id === taskId && statusChanged) {
return { ...t, status: targetColumn as Task["status"], order: orderById.get(t.id) ?? t.order };
}
const order = orderById.get(t.id);
return order !== undefined ? { ...t, order } : t;
}),
};
});
if (statusChanged) {
statusMutation.mutate({ id: taskId, status: targetColumn }); statusMutation.mutate({ id: taskId, status: targetColumn });
} }
reorderMutation.mutate({ orderedIds: targetIds });
}; };
const openTaskDetail = (task: Task) => { const openTaskDetail = (task: Task) => {
navigate({ to: "/tasks/$id", params: { id: task.id } });
};
const openTaskPanel = (task: Task) => {
setSelectedTask(task); setSelectedTask(task);
setPanelOpen(true); setPanelOpen(true);
}; };
@@ -233,7 +334,10 @@ function TasksPage() {
const columns = useMemo(() => { const columns = useMemo(() => {
return STATUS_COLUMNS.map((col) => ({ return STATUS_COLUMNS.map((col) => ({
...col, ...col,
tasks: tasks.filter((t) => t.status === col.id), color: TASK_STATUS[col.id].dot,
tasks: tasks
.filter((t) => t.status === col.id)
.sort((a, b) => a.order - b.order),
})); }));
}, [tasks]); }, [tasks]);
@@ -280,12 +384,14 @@ function TasksPage() {
</div> </div>
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center py-12 text-muted-foreground">Loading tasks...</div> <LoadingState label="Loading tasks..." />
) : isError ? (
<ErrorState message="Failed to load tasks." onRetry={() => refetch()} />
) : view === "board" ? ( ) : view === "board" ? (
<DndContext sensors={sensors} collisionDetection={closestCorners} onDragStart={handleDragStart} onDragEnd={handleDragEnd}> <DndContext sensors={sensors} collisionDetection={closestCorners} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{columns.map((col) => ( {columns.map((col) => (
<div key={col.id} className="bg-muted/50 rounded-lg p-3"> <ColumnDroppable key={col.id} id={col.id} className="bg-muted/50 rounded-lg p-3">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className={cn("w-2 h-2 rounded-full", col.color)} /> <div className={cn("w-2 h-2 rounded-full", col.color)} />
@@ -296,14 +402,14 @@ function TasksPage() {
<SortableContext items={col.tasks.map((t) => t.id)} strategy={verticalListSortingStrategy}> <SortableContext items={col.tasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-2 min-h-[100px]"> <div className="space-y-2 min-h-[100px]">
{col.tasks.map((task) => ( {col.tasks.map((task) => (
<SortableTaskCard key={task.id} task={task} onClick={() => openTaskDetail(task)} /> <SortableTaskCard key={task.id} task={task} onClick={() => openTaskDetail(task)} onEdit={() => openTaskPanel(task)} />
))} ))}
{col.tasks.length === 0 && ( {col.tasks.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-4">No tasks</p> <p className="text-xs text-muted-foreground text-center py-4">No tasks</p>
)} )}
</div> </div>
</SortableContext> </SortableContext>
</div> </ColumnDroppable>
))} ))}
</div> </div>
<DragOverlay> <DragOverlay>
@@ -325,16 +431,18 @@ function TasksPage() {
<TableBody> <TableBody>
{tasks.length === 0 ? ( {tasks.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">No tasks found</TableCell> <TableCell colSpan={5}>
<EmptyState title="No tasks found" />
</TableCell>
</TableRow> </TableRow>
) : tasks.map((task) => ( ) : tasks.map((task) => (
<TableRow key={task.id} className="cursor-pointer" onClick={() => openTaskDetail(task)}> <TableRow key={task.id} className="cursor-pointer" onClick={() => openTaskDetail(task)}>
<TableCell className="font-medium">{task.title}</TableCell> <TableCell className="font-medium">{task.title}</TableCell>
<TableCell> <TableCell>
<Badge variant="secondary" className="text-[10px]">{task.status.replace("_", " ")}</Badge> <Badge className={cn("text-[10px]", TASK_STATUS[task.status]?.badge)}>{task.status.replace("_", " ")}</Badge>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant="outline" className={cn("text-[10px]", PRIORITY_COLORS[task.priority])}>{task.priority}</Badge> <Badge variant="outline" className={cn("text-[10px]", PRIORITY[task.priority]?.badge)}>{task.priority}</Badge>
</TableCell> </TableCell>
<TableCell className="text-sm text-muted-foreground"> <TableCell className="text-sm text-muted-foreground">
{task.dueDate ? new Date(task.dueDate).toLocaleDateString() : "-"} {task.dueDate ? new Date(task.dueDate).toLocaleDateString() : "-"}
@@ -345,7 +453,7 @@ function TasksPage() {
<Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button> <Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openTaskDetail(task)}>Edit</DropdownMenuItem> <DropdownMenuItem onClick={() => openTaskPanel(task)}>Edit</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive" onClick={() => deleteMutation.mutate(task.id)}>Delete</DropdownMenuItem> <DropdownMenuItem className="text-destructive" onClick={() => deleteMutation.mutate(task.id)}>Delete</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
+13 -27
View File
@@ -5,23 +5,12 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { TagManager } from "@/components/entities/tag-manager";
import { CustomFieldsDisplay } from "@/components/custom-fields/custom-fields-display";
import { ArrowLeft, Calendar, Clock, ListTodo } from "lucide-react"; import { ArrowLeft, Calendar, Clock, ListTodo } from "lucide-react";
import type { Task } from "@/lib/types"; import type { Task } from "@/lib/types";
import { format, parseISO } from "date-fns"; import { format, parseISO } from "date-fns";
import { TASK_STATUS, PRIORITY } from "@/lib/status-colors";
const STATUS_COLORS: Record<string, string> = {
todo: "bg-slate-500",
in_progress: "bg-blue-500",
done: "bg-green-500",
cancelled: "bg-red-500",
};
const PRIORITY_COLORS: Record<string, string> = {
low: "bg-slate-400",
medium: "bg-amber-500",
high: "bg-orange-500",
urgent: "bg-red-500",
};
function TaskDetail() { function TaskDetail() {
const { id } = useParams({ from: Route.id }); const { id } = useParams({ from: Route.id });
@@ -39,14 +28,19 @@ function TaskDetail() {
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<ListTodo className="h-6 w-6 text-muted-foreground" />
<CardTitle className="text-2xl">{task.title}</CardTitle> <CardTitle className="text-2xl">{task.title}</CardTitle>
<Badge className={STATUS_COLORS[task.status] || "bg-slate-500"}>{task.status.replace("_", " ")}</Badge> <Badge className={TASK_STATUS[task.status]?.badge}>{TASK_STATUS[task.status]?.label ?? task.status}</Badge>
<Badge variant="outline" className={PRIORITY_COLORS[task.priority]}> <Badge variant="outline" className={PRIORITY[task.priority]?.badge}>
{task.priority} {PRIORITY[task.priority]?.label ?? task.priority}
</Badge> </Badge>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Created {format(parseISO(task.createdAt), "MMM d, yyyy HH:mm")}</span>
<span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Updated {format(parseISO(task.updatedAt), "MMM d, yyyy HH:mm")}</span>
</div>
{task.description && ( {task.description && (
<div> <div>
<h3 className="text-sm font-semibold text-muted-foreground mb-1">Description</h3> <h3 className="text-sm font-semibold text-muted-foreground mb-1">Description</h3>
@@ -72,16 +66,8 @@ function TaskDetail() {
<span>Status: {task.status.replace("_", " ")}</span> <span>Status: {task.status.replace("_", " ")}</span>
</div> </div>
</div> </div>
{task.tags && task.tags.length > 0 && ( <CustomFieldsDisplay entityType="tasks" values={task.customFields} />
<div> <TagManager entityType="task" entityId={task.id} tags={task.tags || []} />
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Tags</h3>
<div className="flex flex-wrap gap-2">
{task.tags.map((t: any) => (
<Badge key={t.id || t.name} variant="secondary">{t.name || t}</Badge>
))}
</div>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
+77 -35
View File
@@ -1,16 +1,24 @@
import { createRoute, useNavigate } from "@tanstack/react-router"; import { createRoute, useNavigate } from "@tanstack/react-router";
import { Route as rootRoute } from "./__root"; import { Route as rootRoute } from "./__root";
import { useState } from "react"; import { useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Loader2, Sparkles } from "lucide-react";
function LoginPage() { function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (isSubmitting) return;
setError(""); setError("");
setIsSubmitting(true);
try { try {
const res = await fetch("/api/auth/credentials", { const res = await fetch("/api/auth/credentials", {
method: "POST", method: "POST",
@@ -25,46 +33,80 @@ function LoginPage() {
navigate({ to: "/" }); navigate({ to: "/" });
} catch { } catch {
setError("Network error"); setError("Network error");
} finally {
setIsSubmitting(false);
} }
}; };
return ( return (
<div className="flex items-center justify-center min-h-screen"> <div className="flex min-h-screen items-center justify-center bg-background px-4">
<form <div className="w-full max-w-sm">
onSubmit={handleSubmit} <div className="mb-6 flex flex-col items-center gap-3 text-center">
className="w-full max-w-sm p-8 space-y-4 border rounded-lg" <span
> className="flex h-12 w-12 items-center justify-center rounded-xl shadow-md"
<h1 className="text-2xl font-bold text-center">Sign In</h1> style={{ backgroundColor: "hsl(var(--accent-hsl))" }}
{error && ( >
<p className="text-sm text-destructive text-center">{error}</p> <Sparkles className="h-6 w-6 text-white" aria-hidden="true" />
)} </span>
<div> <div>
<label className="block text-sm font-medium mb-1">Email</label> <h1 className="text-2xl font-bold tracking-tight">Project E</h1>
<input <p className="mt-1 text-sm text-muted-foreground">
type="email" Sign in to your workspace
value={email} </p>
onChange={(e) => setEmail(e.target.value)} </div>
className="w-full px-3 py-2 border rounded-md bg-background"
required
/>
</div> </div>
<div>
<label className="block text-sm font-medium mb-1">Password</label> <Card className="shadow-md">
<input <CardHeader>
type="password" <CardTitle>Sign in</CardTitle>
value={password} <CardDescription>
onChange={(e) => setPassword(e.target.value)} Enter your credentials to continue
className="w-full px-3 py-2 border rounded-md bg-background" </CardDescription>
required </CardHeader>
/> <CardContent>
</div> {error && (
<button <div
type="submit" role="alert"
className="w-full py-2 px-4 bg-primary text-primary-foreground rounded-md hover:opacity-90" className="mb-4 rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive"
> >
Sign In {error}
</button> </div>
</form> )}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
autoComplete="email"
autoFocus
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</div>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting && (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
)}
{isSubmitting ? "Signing in..." : "Sign in"}
</Button>
</form>
</CardContent>
</Card>
</div>
</div> </div>
); );
} }
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+3
View File
@@ -6,6 +6,9 @@ export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"], content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: { theme: {
extend: { extend: {
fontFamily: {
sans: ["var(--font-sans)"],
},
colors: { colors: {
border: "hsl(var(--border))", border: "hsl(var(--border))",
input: "hsl(var(--input))", input: "hsl(var(--input))",
+2 -1
View File
@@ -11,7 +11,8 @@
"dependencies": { "dependencies": {
"@project-e/db": "^0.1.0", "@project-e/db": "^0.1.0",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"postgres": "^3.4.9" "postgres": "^3.4.9",
"rrule": "^2.8.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.19.0", "@types/node": "^22.19.0",
+372 -25
View File
@@ -1,35 +1,382 @@
import { db, sql } from "@project-e/db/client"; import { db, jobs, webhooks, webhookDeliveries, scheduledJobs, tasks } from '@project-e/db';
import { and, eq, lte, isNull, or } from 'drizzle-orm';
import { createHmac } from 'node:crypto';
import { RRule } from 'rrule';
let running = true; const POLL_INTERVAL_BASE = 5000; // 5 seconds base
const POLL_INTERVAL_MAX = 60000; // 60 seconds max
const MAX_RETRIES = 6;
process.on("SIGTERM", async () => { let currentPollInterval = POLL_INTERVAL_BASE;
console.log("worker: SIGTERM received, shutting down gracefully..."); let isProcessing = false;
running = false; let shutdownRequested = false;
await sql.end();
process.exit(0);
});
process.on("SIGINT", async () => { // ── Job processing ───────────────────────────────────────────────────────────────
console.log("worker: SIGINT received, shutting down gracefully...");
running = false; async function poll(): Promise<void> {
await sql.end(); if (isProcessing || shutdownRequested) return;
process.exit(0); isProcessing = true;
});
async function main() {
try { try {
// Test DB connection // Enqueue recurring_spawn jobs for due scheduled jobs before polling the
await sql`SELECT 1`; // queue so they are picked up in the same iteration.
console.log("worker ready"); const scheduledEnqueued = await processDueScheduledJobs();
} catch (err) {
console.error("worker: failed to connect to database:", err); const now = new Date();
process.exit(1);
// Get pending jobs that are due.
// nextRetryAt is NULL for freshly-queued jobs, which are due immediately.
const pendingJobs = await db.select()
.from(jobs)
.where(and(
eq(jobs.status, 'pending'),
or(isNull(jobs.nextRetryAt), lte(jobs.nextRetryAt, now)),
))
.orderBy(jobs.createdAt)
.limit(10);
if (pendingJobs.length > 0) {
console.log(`[Worker] Processing ${pendingJobs.length} job(s)`);
for (const job of pendingJobs) {
if (shutdownRequested) break;
await processJob(job);
}
// Reset poll interval on success
currentPollInterval = POLL_INTERVAL_BASE;
} else if (scheduledEnqueued > 0) {
// Scheduled jobs were enqueued but their recurring_spawn jobs will be
// processed next iteration — keep the poll fast.
currentPollInterval = POLL_INTERVAL_BASE;
} else {
// No jobs — increase poll interval (backoff)
currentPollInterval = Math.min(currentPollInterval * 1.5, POLL_INTERVAL_MAX);
}
} catch (error) {
console.error('[Worker] Poll error:', error);
} finally {
isProcessing = false;
} }
// Stub loop: sleep forever, handle signals if (!shutdownRequested) {
while (running) { setTimeout(poll, currentPollInterval);
await new Promise((resolve) => setTimeout(resolve, 10000));
} }
} }
main(); async function processJob(job: typeof jobs.$inferSelect): Promise<void> {
// Mark as processing
await db.update(jobs)
.set({ status: 'processing', updatedAt: new Date() })
.where(eq(jobs.id, job.id));
try {
switch (job.type) {
case 'webhook_delivery':
await handleWebhookDelivery(job);
break;
case 'recurring_spawn':
await handleRecurringSpawn(job);
break;
case 'ai_dispatch':
await handleAiDispatch(job);
break;
default:
console.warn(`[Worker] Unknown job type: ${job.type}`);
await db.update(jobs)
.set({ status: 'failed', lastError: `Unknown job type: ${job.type}`, updatedAt: new Date() })
.where(eq(jobs.id, job.id));
return;
}
// Mark as completed
await db.update(jobs)
.set({ status: 'completed', updatedAt: new Date() })
.where(eq(jobs.id, job.id));
console.log(`[Worker] Job ${job.id} (${job.type}) completed`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const attempts = (job.attempts || 0) + 1;
const maxAttempts = job.maxAttempts || MAX_RETRIES;
if (attempts >= maxAttempts) {
// Max retries reached — mark as failed
await db.update(jobs)
.set({
status: 'failed',
attempts,
lastError: errorMessage,
updatedAt: new Date(),
})
.where(eq(jobs.id, job.id));
console.error(`[Worker] Job ${job.id} (${job.type}) failed after ${attempts} attempts: ${errorMessage}`);
} else {
// Schedule retry with exponential backoff
const backoffMs = Math.min(2000 * Math.pow(2, attempts), 300000); // Max 5 minutes
const nextRetry = new Date(Date.now() + backoffMs);
await db.update(jobs)
.set({
status: 'pending',
attempts,
lastError: errorMessage,
nextRetryAt: nextRetry,
updatedAt: new Date(),
})
.where(eq(jobs.id, job.id));
console.log(`[Worker] Job ${job.id} (${job.type}) retry ${attempts}/${MAX_RETRIES} scheduled for ${nextRetry.toISOString()}`);
}
}
}
// ── Recurring schedule advancement ───────────────────────────────────────────────
/**
* Find scheduled jobs whose next occurrence is due, enqueue a `recurring_spawn`
* job for each, and immediately advance (or remove) the scheduled job so it is
* never enqueued twice. `handleRecurringSpawn` only spawns the entity — the
* schedule bookkeeping happens here so the enqueue and the advance are atomic
* within the same poll iteration.
*/
async function processDueScheduledJobs(): Promise<number> {
try {
const now = new Date();
const dueScheduled = await db.select()
.from(scheduledJobs)
.where(lte(scheduledJobs.nextOccurrenceAt, now))
.orderBy(scheduledJobs.nextOccurrenceAt)
.limit(10);
for (const scheduled of dueScheduled) {
if (shutdownRequested) break;
await db.insert(jobs).values({
type: 'recurring_spawn',
payload: { scheduled_job_id: scheduled.id },
status: 'pending',
});
console.log(`[Worker] Enqueued recurring spawn for scheduled job ${scheduled.id} (${scheduled.entityType}:${scheduled.entityId})`);
await advanceScheduledJob(scheduled);
}
return dueScheduled.length;
} catch (error) {
console.error('[Worker] processDueScheduledJobs error:', error);
return 0;
}
}
async function advanceScheduledJob(scheduled: typeof scheduledJobs.$inferSelect): Promise<void> {
const now = new Date();
try {
const rule = RRule.fromString(scheduled.recurrenceRule);
// Compute from the due occurrence so no occurrence is skipped even if the
// spawned job is processed later than the original due time.
const base = scheduled.nextOccurrenceAt > now ? scheduled.nextOccurrenceAt : now;
const nextOccurrence = rule.after(base);
if (nextOccurrence) {
await db.update(scheduledJobs)
.set({
nextOccurrenceAt: nextOccurrence,
lastSpawnedAt: now,
})
.where(eq(scheduledJobs.id, scheduled.id));
console.log(`[Worker] Next occurrence for ${scheduled.entityId} at ${nextOccurrence.toISOString()}`);
} else {
// No more occurrences — remove the scheduled job
await db.delete(scheduledJobs).where(eq(scheduledJobs.id, scheduled.id));
console.log(`[Worker] No more occurrences for ${scheduled.entityId}, removing scheduled job`);
}
} catch (error) {
console.error(`[Worker] Failed to compute next occurrence for ${scheduled.entityId}:`, error);
// If rrule parsing fails, just advance by 1 day as fallback
const nextDay = new Date(now.getTime() + 24 * 60 * 60 * 1000);
await db.update(scheduledJobs)
.set({
nextOccurrenceAt: nextDay,
lastSpawnedAt: now,
})
.where(eq(scheduledJobs.id, scheduled.id));
}
}
// ── Webhook delivery ─────────────────────────────────────────────────────────────
async function handleWebhookDelivery(job: typeof jobs.$inferSelect): Promise<void> {
const payload = job.payload as {
webhook_id: string;
event: string;
entity_type: string;
entity_id: string;
data: unknown;
timestamp: string;
workspace_id: string;
};
const [webhook] = await db.select()
.from(webhooks)
.where(eq(webhooks.id, payload.webhook_id))
.limit(1);
if (!webhook) {
throw new Error(`Webhook ${payload.webhook_id} not found`);
}
if (!webhook.active) {
console.log(`[Worker] Webhook ${webhook.id} is inactive, skipping delivery`);
return;
}
const deliveryPayload = {
event: payload.event,
entity_type: payload.entity_type,
entity_id: payload.entity_id,
data: payload.data,
timestamp: payload.timestamp || new Date().toISOString(),
workspace_id: payload.workspace_id,
};
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Event-Type': payload.event,
};
if (webhook.secret) {
const body = JSON.stringify(deliveryPayload);
const signature = createHmac('sha256', webhook.secret)
.update(body)
.digest('hex');
headers['X-ProjectE-Signature'] = signature;
}
let responseStatus = 0;
let responseBody = '';
let success = false;
try {
const response = await fetch(webhook.url, {
method: 'POST',
headers,
body: JSON.stringify(deliveryPayload),
signal: AbortSignal.timeout(10000),
});
responseStatus = response.status;
responseBody = await response.text();
success = response.ok;
} catch (error) {
responseBody = error instanceof Error ? error.message : String(error);
success = false;
}
// Record delivery
await db.insert(webhookDeliveries).values({
webhookId: webhook.id,
event: payload.event,
payload: deliveryPayload as Record<string, unknown>,
status: success ? 'success' : 'failed',
statusCode: responseStatus,
responseBody: responseBody.substring(0, 1000),
attempts: job.attempts || 0,
});
if (!success) {
throw new Error(`Webhook delivery failed: ${responseStatus} ${responseBody}`);
}
}
// ── Recurring spawn ──────────────────────────────────────────────────────────────
async function handleRecurringSpawn(job: typeof jobs.$inferSelect): Promise<void> {
const payload = job.payload as {
scheduled_job_id: string;
};
const [scheduled] = await db.select()
.from(scheduledJobs)
.where(eq(scheduledJobs.id, payload.scheduled_job_id))
.limit(1);
if (!scheduled) {
throw new Error(`Scheduled job ${payload.scheduled_job_id} not found`);
}
if (scheduled.entityType === 'task') {
// Fetch the original task to clone
const [originalTask] = await db.select()
.from(tasks)
.where(and(eq(tasks.id, scheduled.entityId), isNull(tasks.deletedAt)))
.limit(1);
if (originalTask) {
// Create a new task instance
await db.insert(tasks).values({
title: originalTask.title,
description: originalTask.description,
status: 'todo',
priority: originalTask.priority,
domainId: originalTask.domainId,
projectId: originalTask.projectId,
sectionId: originalTask.sectionId,
dueDate: originalTask.dueDate,
estimatedMinutes: originalTask.estimatedMinutes,
recurrenceRule: originalTask.recurrenceRule,
order: originalTask.order,
customFields: originalTask.customFields,
});
console.log(`[Worker] Spawned new task instance for ${scheduled.entityId}`);
}
} else if (scheduled.entityType === 'habit') {
// For habits, we just log — habit completions are user-driven
console.log(`[Worker] Habit ${scheduled.entityId} recurrence tick (user-driven)`);
}
}
// ── AI Dispatch (disabled) ───────────────────────────────────────────────────────
async function handleAiDispatch(job: typeof jobs.$inferSelect): Promise<void> {
const payload = job.payload as {
entity_type?: string;
entity_id?: string;
instruction?: string;
user_id?: string;
};
console.log(`[Worker] ai_dispatch is disabled — job ${job.id} completed without action`, JSON.stringify(payload));
// DISABLED: ai_dispatch is not wired up yet. Jobs of this type are allowed to
// complete (non-destructive) so the queue does not stall on them. Future:
// connect to actual AI agent.
}
// ── Graceful shutdown ────────────────────────────────────────────────────────────
function setupGracefulShutdown(): void {
process.on('SIGTERM', () => {
console.log('[Worker] SIGTERM received, shutting down gracefully...');
shutdownRequested = true;
setTimeout(() => {
console.log('[Worker] Forced exit after timeout');
process.exit(0);
}, 10000).unref();
});
process.on('SIGINT', () => {
console.log('[Worker] SIGINT received, shutting down...');
shutdownRequested = true;
process.exit(0);
});
}
// ── Start ────────────────────────────────────────────────────────────────────────
console.log('[Worker] Starting Project E worker...');
console.log('[Worker] PostgreSQL queue via Drizzle ORM');
console.log(`[Worker] Poll interval: ${POLL_INTERVAL_BASE}ms (base), ${POLL_INTERVAL_MAX}ms (max)`);
setupGracefulShutdown();
// Start polling
setTimeout(poll, POLL_INTERVAL_BASE);
+7 -1
View File
@@ -25,6 +25,7 @@
"hono": "^4.6.0", "hono": "^4.6.0",
"jose": "^5.9.6", "jose": "^5.9.6",
"postgres": "^3.4.9", "postgres": "^3.4.9",
"rrule": "^2.8.1",
"zod": "^4.4.3", "zod": "^4.4.3",
}, },
"devDependencies": { "devDependencies": {
@@ -90,6 +91,7 @@
"react-force-graph-2d": "^1.29.1", "react-force-graph-2d": "^1.29.1",
"react-hook-form": "^7.84.0", "react-hook-form": "^7.84.0",
"recharts": "^3.10.1", "recharts": "^3.10.1",
"sonner": "^2.0.8",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"zod": "^4.4.3", "zod": "^4.4.3",
@@ -99,6 +101,7 @@
"@tanstack/react-query-devtools": "^5.62.0", "@tanstack/react-query-devtools": "^5.62.0",
"@tanstack/react-router-devtools": "^1.167.0", "@tanstack/react-router-devtools": "^1.167.0",
"@types/react": "^19.1.0", "@types/react": "^19.1.0",
"@types/react-big-calendar": "^1.16.3",
"@types/react-dom": "^19.1.0", "@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.5.2", "autoprefixer": "^10.5.2",
@@ -198,6 +201,7 @@
"@project-e/db": "^0.1.0", "@project-e/db": "^0.1.0",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"postgres": "^3.4.9", "postgres": "^3.4.9",
"rrule": "^2.8.1",
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.19.0", "@types/node": "^22.19.0",
@@ -2114,7 +2118,7 @@
"slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], "sonner": ["sonner@2.0.8", "", { "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
@@ -2332,6 +2336,8 @@
"@project-e/web-legacy/react-hook-form": ["react-hook-form@7.83.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-AXt8cMCmx5a7u4uvpb2uRFVrWQhllI4pV+LSykxIac/hjt44TnQkmX9BKuQi2i+LDC62esmiLpilkav+kjVf/A=="], "@project-e/web-legacy/react-hook-form": ["react-hook-form@7.83.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-AXt8cMCmx5a7u4uvpb2uRFVrWQhllI4pV+LSykxIac/hjt44TnQkmX9BKuQi2i+LDC62esmiLpilkav+kjVf/A=="],
"@project-e/web-legacy/sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
"@tailwindcss/node/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], "@tailwindcss/node/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"@tailwindcss/node/tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], "@tailwindcss/node/tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
+847 -761
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
import { test, expect } from '@playwright/test';
import { login } from './helpers/auth';
test.describe('Agent Activity', () => {
test.beforeEach(async ({ page }) => {
await login(page);
await page.goto('/agents/activity');
await expect(page.getByRole('heading', { name: /agent activity/i })).toBeVisible();
});
test('renders the filter bar and refresh button', async ({ page }) => {
await expect(page.getByRole('combobox', { name: /all agents/i })).toBeVisible();
await expect(page.getByRole('combobox', { name: /all actions/i })).toBeVisible();
await expect(page.getByPlaceholder('From')).toBeVisible();
await expect(page.getByPlaceholder('To')).toBeVisible();
await expect(page.getByRole('button', { name: /refresh/i })).toBeVisible();
});
test('renders the timeline area', async ({ page }) => {
// The initial activity query settles to either the empty state or a row of
// live/fetched activity entries.
const emptyState = page.getByText(/no activity found/i);
const activityEntry = page.getByText(/created|updated|deleted|completed/i).last();
await expect(emptyState.or(activityEntry)).toBeVisible({ timeout: 10_000 });
});
test('filters by action type', async ({ page }) => {
await page.getByRole('combobox', { name: /all actions/i }).click();
await page.getByRole('option', { name: 'Created', exact: true }).click();
// The trigger now shows the selected action and the list refetches.
await expect(page.getByRole('combobox', { name: 'Created' })).toBeVisible({
timeout: 10_000,
});
});
});
+10 -75
View File
@@ -5,87 +5,22 @@ test.describe('Analytics', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await login(page); await login(page);
await page.goto('/analytics'); await page.goto('/analytics');
// Wait for the analytics page to load
await expect(page.getByRole('heading', { name: /analytics/i })).toBeVisible(); await expect(page.getByRole('heading', { name: /analytics/i })).toBeVisible();
}); });
test.describe('Analytics page', () => { test('renders the analytics cards', async ({ page }) => {
test('should display analytics heading and tagline', async ({ page }) => { await expect(page.getByText(/tasks completed/i)).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole('heading', { name: /analytics/i })).toBeVisible(); await expect(page.getByText(/created vs completed/i)).toBeVisible();
await expect(page.getByText(/patterns behind your progress/i)).toBeVisible(); await expect(page.getByText(/habit completion/i)).toBeVisible();
}); await expect(page.getByText(/productivity heatmap/i)).toBeVisible();
}); });
test.describe('Summary cards', () => { test('switches the reporting range', async ({ page }) => {
test('should display summary stat cards', async ({ page }) => { await page.getByRole('combobox', { name: /last 30 days/i }).click();
// Should show 4 stat cards: Task Completion, Habit Consistency, Time Tracked, Active Streaks await page.getByRole('option', { name: /last 90 days/i }).click();
await expect(page.getByText(/task completion/i)).toBeVisible();
await expect(page.getByText(/habit consistency/i)).toBeVisible();
await expect(page.getByText(/time tracked/i)).toBeVisible();
await expect(page.getByText(/active streaks/i)).toBeVisible();
});
test('should display percentage values', async ({ page }) => { await expect(page.getByRole('combobox', { name: /last 90 days/i })).toBeVisible({
// Task completion and habit consistency should show percentages timeout: 10_000,
const percentElements = page.locator('text=/\\d+%/');
const count = await percentElements.count();
expect(count).toBeGreaterThanOrEqual(2);
});
});
test.describe('Analytics tabs', () => {
test('should show Trends, Habits, and Time tabs', async ({ page }) => {
await expect(page.getByRole('tab', { name: /trends/i })).toBeVisible();
await expect(page.getByRole('tab', { name: /habits/i })).toBeVisible();
await expect(page.getByRole('tab', { name: /time/i })).toBeVisible();
});
test('should default to Trends tab', async ({ page }) => {
await expect(page.getByRole('tab', { name: /trends/i })).toHaveAttribute('data-state', 'active');
});
test('should switch to Habits tab', async ({ page }) => {
await page.getByRole('tab', { name: /habits/i }).click();
await expect(page.getByRole('tab', { name: /habits/i })).toHaveAttribute('data-state', 'active');
// Wait for chart to load
await page.waitForTimeout(1_000);
});
test('should switch to Time tab', async ({ page }) => {
await page.getByRole('tab', { name: /time/i }).click();
await expect(page.getByRole('tab', { name: /time/i })).toHaveAttribute('data-state', 'active');
// Wait for chart to load
await page.waitForTimeout(1_000);
});
test('should switch between all tabs', async ({ page }) => {
// Start on Trends
await expect(page.getByRole('tab', { name: /trends/i })).toHaveAttribute('data-state', 'active');
// Switch to Habits
await page.getByRole('tab', { name: /habits/i }).click();
await expect(page.getByRole('tab', { name: /habits/i })).toHaveAttribute('data-state', 'active');
// Switch to Time
await page.getByRole('tab', { name: /time/i }).click();
await expect(page.getByRole('tab', { name: /time/i })).toHaveAttribute('data-state', 'active');
// Switch back to Trends
await page.getByRole('tab', { name: /trends/i }).click();
await expect(page.getByRole('tab', { name: /trends/i })).toHaveAttribute('data-state', 'active');
});
});
test.describe('Analytics charts', () => {
test('should load chart components for each tab', async ({ page }) => {
// Wait for charts to render (recharts is lazy loaded)
await page.waitForTimeout(3_000);
// Verify the page has rendered without errors
const mainContent = page.locator('main');
await expect(mainContent).toBeVisible();
}); });
}); });
}); });
+40 -80
View File
@@ -3,103 +3,63 @@ import { login, logout, goToLogin } from './helpers/auth';
import { TEST_USER, INVALID_USER } from './helpers/fixtures'; import { TEST_USER, INVALID_USER } from './helpers/fixtures';
test.describe('Authentication Flow', () => { test.describe('Authentication Flow', () => {
test.describe('Login', () => { test('logs in with valid credentials and redirects to the dashboard', async ({ page }) => {
test('should login with valid credentials and redirect to dashboard', async ({ page }) => { await goToLogin(page);
await goToLogin(page);
// Verify login page loaded // The login page renders a Card titled "Sign in".
await expect(page.getByRole('heading', { name: /project e/i })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
await expect(page.getByRole('button', { name: /sign in/i })).toBeVisible(); await expect(page.getByRole('button', { name: /sign in/i })).toBeVisible();
// Fill in credentials await page.getByLabel('Email').fill(TEST_USER.email);
await page.getByLabel('Email').fill(TEST_USER.email); await page.getByLabel('Password').fill(TEST_USER.password);
await page.getByLabel('Password').fill(TEST_USER.password); await page.getByRole('button', { name: /sign in/i }).click();
await page.getByRole('button', { name: /sign in/i }).click();
// Should redirect to dashboard // Successful login navigates to the "/" dashboard.
await page.waitForURL('**/dashboard', { timeout: 15_000 }); await page.waitForURL('**/', { timeout: 15_000 });
await expect(page).toHaveURL(/\/dashboard/); await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
});
// Dashboard should be visible test('shows an error for invalid credentials', async ({ page }) => {
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible(); await goToLogin(page);
});
test('should show error with invalid credentials', async ({ page }) => { await page.getByLabel('Email').fill(INVALID_USER.email);
await goToLogin(page); await page.getByLabel('Password').fill(INVALID_USER.password);
await page.getByRole('button', { name: /sign in/i }).click();
await page.getByLabel('Email').fill(INVALID_USER.email); // Stay on the login page and surface the API error in a role="alert" box.
await page.getByLabel('Password').fill(INVALID_USER.password); await expect(page).toHaveURL(/\/login/);
await page.getByRole('button', { name: /sign in/i }).click(); await expect(page.getByRole('alert')).toContainText('Invalid email or password', {
timeout: 10_000,
// Should still be on login page
await expect(page).toHaveURL(/\/login/);
// Should show error message (toast or inline)
// The app uses sonner toasts for errors
await expect(
page.getByText(/login failed|invalid/i),
).toBeVisible({ timeout: 10_000 });
});
test('should prevent submitting empty form', async ({ page }) => {
await goToLogin(page);
const submitButton = page.getByRole('button', { name: /sign in/i });
await expect(submitButton).toBeVisible();
// HTML5 required attribute should prevent submission
// The email field is required
await page.getByLabel('Password').fill('somepassword');
await submitButton.click();
// Should stay on login page (HTML5 validation prevents submit)
await expect(page).toHaveURL(/\/login/);
}); });
}); });
test.describe('Logout', () => { test('logs out from the user menu and returns to the login page', async ({ page }) => {
test('should logout and redirect to login page', async ({ page }) => { await login(page);
// First login await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
await login(page);
await expect(page).toHaveURL(/\/dashboard/);
// Clear cookies to simulate logout // The topbar avatar dropdown works on every viewport (the sidebar user menu
await logout(page); // is desktop-only), so use it for the logout flow.
const avatarButton = page.getByRole('banner').getByRole('button').last();
await avatarButton.click();
await page.getByRole('menuitem', { name: /log out/i }).click();
// Navigate to dashboard – should redirect to login await page.waitForURL('**/login', { timeout: 15_000 });
await page.goto('/dashboard'); await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
await page.waitForURL('**/login', { timeout: 10_000 });
await expect(page).toHaveURL(/\/login/);
});
}); });
test.describe('Session persistence', () => { test('keeps the session after a page reload', async ({ page }) => {
test('should stay logged in after page refresh', async ({ page }) => { await login(page);
await login(page);
await expect(page).toHaveURL(/\/dashboard/);
// Refresh the page await page.reload();
await page.reload();
// Should still be on dashboard await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
});
}); });
test.describe('Protected routes', () => { test('redirects unauthenticated users from protected pages to login', async ({ page }) => {
test('should redirect unauthenticated users from dashboard to login', async ({ page }) => { await logout(page);
await logout(page);
await page.goto('/dashboard');
await page.waitForURL('**/login', { timeout: 10_000 });
await expect(page).toHaveURL(/\/login/);
});
test('should redirect unauthenticated users from tasks to login', async ({ page }) => { await page.goto('/tasks');
await logout(page); await page.waitForURL('**/login', { timeout: 15_000 });
await page.goto('/tasks'); await expect(page).toHaveURL(/\/login/);
await page.waitForURL('**/login', { timeout: 10_000 });
await expect(page).toHaveURL(/\/login/);
});
}); });
}); });
+56 -35
View File
@@ -1,5 +1,6 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { login } from './helpers/auth'; import { login } from './helpers/auth';
import { testCalendarEvents } from './helpers/fixtures';
test.describe('Calendar', () => { test.describe('Calendar', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
@@ -8,47 +9,67 @@ test.describe('Calendar', () => {
await expect(page.getByRole('heading', { name: /calendar/i })).toBeVisible(); await expect(page.getByRole('heading', { name: /calendar/i })).toBeVisible();
}); });
test('should display calendar with month view by default', async ({ page }) => { /** Fill the New Event dialog and submit it, returning the POST response. */
// Month view should be visible async function createEvent(page: import('@playwright/test').Page, title: string) {
await expect(page.getByText(/today/i)).toBeVisible(); await page.getByRole('button', { name: /new event/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('heading', { name: 'New Event' })).toBeVisible();
await dialog.getByLabel('Title').fill(title);
// The API requires an ISO start time; schedule the event for today so it
// lands in the current month view.
const pad = (n: number) => String(n).padStart(2, '0');
const start = new Date();
const end = new Date(start.getTime() + 60 * 60 * 1000);
const toLocal = (d: Date) =>
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
await dialog.getByLabel('Start').fill(toLocal(start));
await dialog.getByLabel('End').fill(toLocal(end));
const createResponse = page.waitForResponse(
(resp) => resp.url().includes('/api/calendar/events') && resp.request().method() === 'POST',
);
await dialog.getByRole('button', { name: /create event/i }).click();
await createResponse;
await expect(dialog).toBeHidden();
}
test('renders the calendar page', async ({ page }) => {
// react-big-calendar only mounts once events exist; otherwise a friendly
// empty state is shown. Accept either so a fresh DB still passes.
await expect(
page.getByText(/no events yet/i).or(page.locator('.rbc-calendar-container')),
).toBeVisible({ timeout: 10_000 });
}); });
test('should switch between month, week, and day views', async ({ page }) => { test('creates an event via the New Event dialog', async ({ page }) => {
// Click week view await createEvent(page, testCalendarEvents.title);
const weekBtn = page.getByRole('button', { name: /week/i });
if (await weekBtn.isVisible()) {
await weekBtn.click();
await page.waitForTimeout(500);
}
// Click day view // The calendar now renders and the event shows up on it.
const dayBtn = page.getByRole('button', { name: /day/i }); await expect(page.locator('.rbc-calendar-container')).toBeVisible({ timeout: 10_000 });
if (await dayBtn.isVisible()) { await expect(
await dayBtn.click(); page.locator('.rbc-event').filter({ hasText: testCalendarEvents.title }),
await page.waitForTimeout(500); ).toBeVisible({ timeout: 10_000 });
}
// Click month view
const monthBtn = page.getByRole('button', { name: /month/i });
if (await monthBtn.isVisible()) {
await monthBtn.click();
await page.waitForTimeout(500);
}
}); });
test('should navigate between months', async ({ page }) => { test('switches between month, week, and day views', async ({ page }) => {
// Click next month // The toolbar only exists when the calendar is rendered, so make sure at
const nextBtn = page.getByRole('button', { name: /next/i }); // least one event exists first.
if (await nextBtn.isVisible()) { if (!(await page.locator('.rbc-calendar-container').isVisible().catch(() => false))) {
await nextBtn.click(); await createEvent(page, `${testCalendarEvents.title} view`);
await page.waitForTimeout(500);
} }
// Click previous month await expect(page.locator('.rbc-calendar-container')).toBeVisible({ timeout: 10_000 });
const prevBtn = page.getByRole('button', { name: /prev/i }); await page.getByRole('button', { name: 'Week', exact: true }).click();
if (await prevBtn.isVisible()) { await expect(page.locator('.rbc-calendar-container')).toBeVisible();
await prevBtn.click();
await page.waitForTimeout(500); await page.getByRole('button', { name: 'Day', exact: true }).click();
} await expect(page.locator('.rbc-calendar-container')).toBeVisible();
await page.getByRole('button', { name: 'Month', exact: true }).click();
await expect(page.locator('.rbc-calendar-container')).toBeVisible();
}); });
}); });
+37
View File
@@ -0,0 +1,37 @@
import { test, expect } from '@playwright/test';
import { login } from './helpers/auth';
import { testCanvas } from './helpers/fixtures';
test.describe('Canvas', () => {
test.beforeEach(async ({ page }) => {
await login(page);
await page.goto('/canvas');
await expect(page.getByRole('heading', { name: /canvas/i })).toBeVisible();
});
test('renders the canvas list', async ({ page }) => {
await expect(page.getByRole('button', { name: /new canvas/i })).toBeVisible();
});
test('creates a canvas and opens the editor', async ({ page }) => {
await page.getByRole('button', { name: /new canvas/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('heading', { name: 'New Canvas' })).toBeVisible();
await dialog.getByLabel('Name').fill(testCanvas.name);
const createResponse = page.waitForResponse(
(resp) => resp.url().includes('/api/canvas') && resp.request().method() === 'POST',
);
await dialog.getByRole('button', { name: /^create$/i }).click();
expect((await createResponse).ok()).toBeTruthy();
// Creating a canvas navigates straight into the editor at /canvas/<id>.
await page.waitForURL('**/canvas/*', { timeout: 10_000 });
await expect(page.getByPlaceholder(/type \/ for commands/i)).toBeVisible({
timeout: 10_000,
});
});
});
+41
View File
@@ -0,0 +1,41 @@
import { test, expect } from '@playwright/test';
import { login } from './helpers/auth';
test.describe('Daily Notes', () => {
test.beforeEach(async ({ page }) => {
await login(page);
await page.goto('/daily');
// Wait for the editor (mood/energy controls) to finish loading.
await expect(page.getByRole('radiogroup', { name: 'Mood' })).toBeVisible();
});
test('renders the calendar sidebar and editor', async ({ page }) => {
await expect(page.getByRole('button', { name: /today/i })).toBeVisible();
await expect(page.getByText('Sun')).toBeVisible();
await expect(page.getByRole('radiogroup', { name: 'Energy' })).toBeVisible();
});
test('selecting a mood rating creates the daily note', async ({ page }) => {
await page.getByRole('radio', { name: 'Mood 5' }).click();
await expect(page.getByRole('radio', { name: 'Mood 5' })).toHaveAttribute('aria-checked', 'true');
// Creating a mood on a day with no note materializes the editor.
await expect(page.getByPlaceholder(/write your daily note/i)).toBeVisible({
timeout: 10_000,
});
});
test('writes a daily note and it autosaves', async ({ page }) => {
const textarea = page.getByPlaceholder(/write your daily note/i);
if (!(await textarea.isVisible().catch(() => false))) {
await page.getByRole('radio', { name: 'Mood 5' }).click();
await expect(textarea).toBeVisible({ timeout: 10_000 });
}
await textarea.fill('E2E daily note content');
// Once a note exists the header shows the "Saved" badge.
await expect(page.getByText('Saved', { exact: true })).toBeVisible({ timeout: 10_000 });
});
});
+14 -11
View File
@@ -4,20 +4,23 @@ import { login } from './helpers/auth';
test.describe('Dashboard', () => { test.describe('Dashboard', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await login(page); await login(page);
await page.goto('/dashboard'); });
test('renders the dashboard with the widget area', async ({ page }) => {
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible(); await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
// Either the widget grid renders or the "add widget" entry point shows.
await expect(page.getByRole('button', { name: /add widget/i })).toBeVisible();
}); });
test('should display dashboard with widgets', async ({ page }) => { test('adds the default widget set from an empty dashboard', async ({ page }) => {
// Dashboard should show at least one widget area const addDefaults = page.getByRole('button', { name: /add default widgets/i });
await expect(page.locator('[class*="grid"]').first()).toBeVisible();
});
test('should show today tasks widget', async ({ page }) => { // Only the empty dashboard offers the default set; a dashboard that already
await expect(page.getByText(/today/i).first()).toBeVisible(); // has widgets (from earlier runs) skips this.
}); if (await addDefaults.isVisible().catch(() => false)) {
await addDefaults.click();
test('should show activity feed widget', async ({ page }) => { await expect(page.getByText(/tasks due today/i)).toBeVisible({ timeout: 10_000 });
await expect(page.getByText(/activity/i).first()).toBeVisible(); }
}); });
}); });
+17
View File
@@ -0,0 +1,17 @@
/**
* Minimal ambient declarations for the E2E suite.
*
* The suite reads a handful of env vars for test credentials. Node type
* definitions are not installed at the repo root (they only live under
* apps/api/node_modules), so declare just the surface we use instead of
* depending on @types/node.
*/
declare const process: {
env: {
CI?: string;
E2E_EMAIL?: string;
E2E_PASSWORD?: string;
INITIAL_ADMIN_EMAIL?: string;
INITIAL_ADMIN_PASSWORD?: string;
};
};
+26
View File
@@ -0,0 +1,26 @@
import { test, expect } from '@playwright/test';
import { login } from './helpers/auth';
test.describe('Graph', () => {
test.beforeEach(async ({ page }) => {
await login(page);
await page.goto('/graph');
});
test('renders the graph toolbar with search and zoom controls', async ({ page }) => {
await expect(page.getByPlaceholder(/find a node/i)).toBeVisible();
await expect(page.getByRole('button', { name: /filters/i })).toBeVisible();
await expect(page.getByRole('button', { name: /zoom in/i })).toBeVisible();
await expect(page.getByRole('button', { name: /zoom out/i })).toBeVisible();
await expect(page.getByRole('button', { name: /reset view/i })).toBeVisible();
});
test('opens the filters panel with entity type toggles', async ({ page }) => {
await page.getByRole('button', { name: /filters/i }).click();
await expect(page.getByRole('heading', { name: 'Filters' })).toBeVisible();
await expect(page.getByRole('checkbox', { name: 'Tasks' })).toBeVisible();
await expect(page.getByRole('checkbox', { name: 'Habits' })).toBeVisible();
await expect(page.getByRole('checkbox', { name: 'Projects' })).toBeVisible();
});
});
+32 -70
View File
@@ -2,87 +2,49 @@ import { test, expect } from '@playwright/test';
import { login } from './helpers/auth'; import { login } from './helpers/auth';
import { testHabits } from './helpers/fixtures'; import { testHabits } from './helpers/fixtures';
test.describe('Habit Tracking', () => { test.describe('Habits', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await login(page); await login(page);
await page.goto('/habits'); await page.goto('/habits');
// Wait for the habits page to load
await expect(page.getByRole('heading', { name: /habits/i })).toBeVisible(); await expect(page.getByRole('heading', { name: /habits/i })).toBeVisible();
}); });
test.describe('Habits page', () => { test('shows the empty state when there are no habits', async ({ page }) => {
test('should display habits page with summary banner', async ({ page }) => { if (await page.getByText('No habits yet').isVisible().catch(() => false)) {
// Summary banner should show today's progress await expect(page.getByText(/create your first one/i)).toBeVisible();
await expect(page.getByText(/today's progress/i)).toBeVisible(); }
await expect(page.getByText(/completion rate/i)).toBeVisible(); });
});
test('should show "New habit" button', async ({ page }) => { test('creates a habit via the New Habit dialog', async ({ page }) => {
await expect(page.getByRole('button', { name: /new habit/i })).toBeVisible(); await page.getByRole('button', { name: /new habit/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('heading', { name: 'New Habit' })).toBeVisible();
await dialog.getByLabel('Name').fill(testHabits.name);
await dialog.getByLabel('Description').fill(testHabits.description);
await dialog.getByRole('button', { name: /create habit/i }).click();
// The dialog closes and the habit appears in the list.
await expect(dialog).toBeHidden();
await expect(page.getByText(testHabits.name, { exact: true })).toBeVisible({
timeout: 10_000,
}); });
}); });
test.describe('Create habit', () => { test('marks a habit complete from the list', async ({ page }) => {
test('should open new habit dialog when clicking "New habit"', async ({ page }) => { // Ensure the fixture habit exists.
if (!(await page.getByText(testHabits.name, { exact: true }).isVisible().catch(() => false))) {
await page.getByRole('button', { name: /new habit/i }).click(); await page.getByRole('button', { name: /new habit/i }).click();
// A dialog or form should appear const dialog = page.getByRole('dialog');
// The habit creation might be a dialog or inline form await dialog.getByLabel('Name').fill(testHabits.name);
await page.waitForTimeout(500); await dialog.getByRole('button', { name: /create habit/i }).click();
}); await expect(page.getByText(testHabits.name, { exact: true })).toBeVisible({ timeout: 10_000 });
}); }
test.describe('Habit completion', () => { // Completing today's habit bumps the streak to 1 day.
test('should display habit cards in a grid', async ({ page }) => { await page.getByRole('button', { name: /mark .* complete/i }).first().click();
// Habit cards should be in a grid layout await expect(page.getByText('1 day streak', { exact: true })).toBeVisible({ timeout: 10_000 });
const habitCards = page.locator('.grid > div, [class*="habit"]');
// Verify the grid container exists
await expect(page.locator('.grid')).toBeVisible();
});
test('should show consistency heatmap section', async ({ page }) => {
// The heatmap section should exist
await expect(page.getByText(/consistency overview/i)).toBeVisible();
});
});
test.describe('Quick completion mode', () => {
test('should complete a quick-mode habit with a single click', async ({ page }) => {
// Find a habit card with a complete button
const completeButtons = page.locator('button:has-text("Complete"), button[aria-label*="complete"]');
const count = await completeButtons.count();
if (count > 0) {
await completeButtons.first().click();
// Should update without showing a dialog (quick mode)
await page.waitForTimeout(1_000);
} else {
test.skip();
}
});
});
test.describe('Detailed completion mode', () => {
test('should open completion dialog for detailed habits', async ({ page }) => {
// Detailed mode habits open a dialog with mood/quantity fields
const detailedButtons = page.locator('button:has-text("Complete")');
const count = await detailedButtons.count();
if (count > 0) {
// Try clicking - if it's detailed mode, a dialog should open
await detailedButtons.first().click();
await page.waitForTimeout(1_000);
} else {
test.skip();
}
});
});
test.describe('Habit streaks', () => {
test('should display streak information on habit cards', async ({ page }) => {
// Habit cards should show streak/fire icons
const streakElements = page.locator('[class*="streak"], [class*="fire"], [class*="flame"]');
// Just verify the page loaded properly
await expect(page.getByRole('heading', { name: /habits/i })).toBeVisible();
});
}); });
}); });
+25 -7
View File
@@ -4,8 +4,13 @@ import { TEST_USER } from './fixtures';
/** /**
* Log the test user in via the login page. * Log the test user in via the login page.
* *
* This hits the real UI flow (fill form → submit) so the auth cookie is * This exercises the real UI flow (fill form → submit) so the `session` cookie
* set exactly as a real user would experience it. * is set exactly as a real user would experience it. Successful logins navigate
* to the "/" dashboard.
*
* The very first login on an empty database auto-creates the admin user; when
* several parallel workers race that first login one of them can hit a
* transient server error, so the submit is retried once.
*/ */
export async function login( export async function login(
page: Page, page: Page,
@@ -16,17 +21,30 @@ export async function login(
await page.goto('/login'); await page.goto('/login');
await page.getByLabel('Email').fill(email); await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill(password); await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: /sign in/i }).click();
// Wait for navigation away from login page let lastError: unknown;
await page.waitForURL('**/dashboard', { timeout: 15_000 }); for (let attempt = 0; attempt < 2; attempt++) {
await page.getByRole('button', { name: /sign in/i }).click();
try {
// Wait for navigation away from the login page to the "/" dashboard.
await page.waitForURL('**/', { timeout: 10_000 });
return;
} catch (error) {
lastError = error;
// A transient failure leaves the form on the login page — refill and retry.
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill(password);
}
}
throw lastError instanceof Error ? lastError : new Error('Login failed');
} }
/** /**
* Ensure the user is logged out by clearing cookies. * Log the user out by hitting the logout endpoint, then land on the login page.
*/ */
export async function logout(page: Page) { export async function logout(page: Page) {
await page.context().clearCookies(); await page.request.post('/api/auth/logout');
await page.goto('/login');
} }
/** /**
+21 -12
View File
@@ -1,9 +1,14 @@
import type { Page } from '@playwright/test'; /**
* Shared test fixtures.
/** Test user credentials – matches PocketBase seed data or test fixtures. */ *
* The first login against a fresh database auto-creates the admin user from
* INITIAL_ADMIN_EMAIL / INITIAL_ADMIN_PASSWORD (see apps/api/src/routes/auth.ts),
* so the E2E suite prefers those env vars and only falls back to hardcoded
* defaults when running against a pre-seeded local database.
*/
export const TEST_USER = { export const TEST_USER = {
email: 'test@example.com', email: process.env.E2E_EMAIL || process.env.INITIAL_ADMIN_EMAIL || 'test@example.com',
password: 'testpassword123', password: process.env.E2E_PASSWORD || process.env.INITIAL_ADMIN_PASSWORD || 'testpassword123',
}; };
/** Fake credentials that should always fail login. */ /** Fake credentials that should always fail login. */
@@ -17,10 +22,7 @@ const ts = Date.now();
export const testTasks = { export const testTasks = {
title: `E2E Test Task ${ts}`, title: `E2E Test Task ${ts}`,
editedTitle: `E2E Test Task Edited ${ts}`,
description: 'This task was created by the E2E test suite.', description: 'This task was created by the E2E test suite.',
domain: 'personal',
priority: 'high' as const,
}; };
export const testHabits = { export const testHabits = {
@@ -36,14 +38,21 @@ export const testProjects = {
export const testNotes = { export const testNotes = {
title: `E2E Test Note ${ts}`, title: `E2E Test Note ${ts}`,
content: 'This note was created by the E2E test suite.', content: 'This note was created by the E2E test suite.',
domain: 'personal',
}; };
export const testReports = { export const testCalendarEvents = {
title: `E2E Test Report ${ts}`, title: `E2E Test Event ${ts}`,
content: 'This report was created by the E2E test suite.', };
export const testCanvas = {
name: `E2E Test Canvas ${ts}`,
}; };
export const testDomains = { export const testDomains = {
name: `e2e-domain-${ts}`, name: `e2e-domain-${ts}`,
}; };
export const testWebhooks = {
name: `E2E Test Webhook ${ts}`,
url: `https://example.com/hooks/${ts}`,
};
-152
View File
@@ -1,152 +0,0 @@
import { test, expect } from '@playwright/test';
import { login } from './helpers/auth';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
test.describe('Import/Export', () => {
test.beforeEach(async ({ page }) => {
await login(page);
await page.goto('/settings');
// Wait for the settings page to load
await expect(page.getByRole('heading', { name: /settings/i })).toBeVisible();
// Switch to Import & Export tab
await page.getByRole('tab', { name: /import.*export/i }).click();
});
test.describe('Export', () => {
test('should show export section with collection selection', async ({ page }) => {
await expect(page.getByText(/export data/i)).toBeVisible();
await expect(page.getByLabel(/select all/i)).toBeVisible();
await expect(page.getByRole('button', { name: /export to json/i })).toBeVisible();
});
test('should have all collection checkboxes', async ({ page }) => {
const collections = [
'tasks', 'habits', 'projects', 'notes', 'reports',
'milestones', 'domains', 'tags', 'agents', 'webhooks',
];
for (const collection of collections) {
await expect(page.getByLabel(new RegExp(`export ${collection}`, 'i'))).toBeVisible();
}
});
test('should toggle select all checkbox', async ({ page }) => {
const selectAll = page.getByLabel(/select all/i);
await expect(selectAll).toBeChecked();
// Uncheck select all
await selectAll.click();
await page.waitForTimeout(300);
// Individual checkboxes should be unchecked
const tasksCheckbox = page.getByLabel(/export tasks/i);
await expect(tasksCheckbox).not.toBeChecked();
// Check select all again
await selectAll.click();
await page.waitForTimeout(300);
await expect(tasksCheckbox).toBeChecked();
});
test('should export data as JSON', async ({ page }) => {
// Set up download handler
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: /export to json/i }).click();
try {
const download = await downloadPromise;
// Verify download started
const filename = download.suggestedFilename();
expect(filename).toMatch(/project-e-export.*\.json/);
} catch {
// Export might fail if PocketBase isn't running – that's OK for structure test
}
});
});
test.describe('Import', () => {
test('should show import section', async ({ page }) => {
await expect(page.getByText(/import data/i)).toBeVisible();
await expect(page.getByText(/restore from a previously exported/i)).toBeVisible();
await expect(page.getByRole('button', { name: /import from json/i })).toBeVisible();
});
test('should have file upload input', async ({ page }) => {
// The file input is hidden, triggered by the button
const fileInput = page.locator('input[type="file"][accept=".json"]');
await expect(fileInput).toBeAttached();
});
test('should import data from JSON file', async ({ page }) => {
// Create a minimal valid import file
const importData = {
version: 1,
exported_at: new Date().toISOString(),
collections: {
tasks: [],
habits: [],
projects: [],
notes: [],
reports: [],
},
};
const tempDir = os.tmpdir();
const importFile = path.join(tempDir, `e2e-import-${Date.now()}.json`);
fs.writeFileSync(importFile, JSON.stringify(importData, null, 2));
try {
// Trigger file upload
const fileInput = page.locator('input[type="file"][accept=".json"]');
await fileInput.setInputFiles(importFile);
// Confirmation dialog should appear
await expect(page.getByRole('dialog', { name: /confirm import/i })).toBeVisible({ timeout: 5_000 });
// Click Import button in dialog
await page.getByRole('button', { name: /import$/i }).click();
// Wait for import to complete
await page.waitForTimeout(3_000);
} finally {
// Cleanup temp file
try { fs.unlinkSync(importFile); } catch { /* ignore */ }
}
});
test('should show import confirmation dialog', async ({ page }) => {
const importData = {
version: 1,
exported_at: new Date().toISOString(),
collections: { tasks: [], habits: [], projects: [], notes: [], reports: [] },
};
const tempDir = os.tmpdir();
const importFile = path.join(tempDir, `e2e-import-dialog-${Date.now()}.json`);
fs.writeFileSync(importFile, JSON.stringify(importData, null, 2));
try {
const fileInput = page.locator('input[type="file"][accept=".json"]');
await fileInput.setInputFiles(importFile);
// Confirmation dialog
const dialog = page.getByRole('dialog', { name: /confirm import/i });
await expect(dialog).toBeVisible({ timeout: 5_000 });
// Should have Cancel and Import buttons
await expect(dialog.getByRole('button', { name: /cancel/i })).toBeVisible();
await expect(dialog.getByRole('button', { name: /import/i })).toBeVisible();
// Cancel the import
await dialog.getByRole('button', { name: /cancel/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 3_000 });
} finally {
try { fs.unlinkSync(importFile); } catch { /* ignore */ }
}
});
});
});
-90
View File
@@ -1,90 +0,0 @@
import { test, expect } from '@playwright/test';
import { login } from './helpers/auth';
test.describe('MCP Server', () => {
test.beforeEach(async ({ page }) => {
await login(page);
});
test('server/discover should return all expected tools', async ({ page }) => {
const response = await page.request.post('/api/mcp', {
data: {
jsonrpc: '2.0',
method: 'server/discover',
id: 1,
},
});
expect(response.ok()).toBeTruthy();
const body = await response.json();
expect(body.jsonrpc).toBe('2.0');
expect(body.result).toBeDefined();
expect(body.result.name).toBe('project-e');
expect(body.result.tools).toBeDefined();
expect(Array.isArray(body.result.tools)).toBeTruthy();
// Verify expected tools exist
const toolNames = body.result.tools.map((t: { name: string }) => t.name);
expect(toolNames).toContain('tasks.list');
expect(toolNames).toContain('tasks.create');
expect(toolNames).toContain('tasks.update');
expect(toolNames).toContain('tasks.delete');
expect(toolNames).toContain('tasks.complete');
expect(toolNames).toContain('habits.list');
expect(toolNames).toContain('habits.create');
expect(toolNames).toContain('habits.complete');
expect(toolNames).toContain('projects.list');
expect(toolNames).toContain('projects.create');
expect(toolNames).toContain('notes.list');
expect(toolNames).toContain('notes.create');
expect(toolNames).toContain('notes.update');
expect(toolNames).toContain('notes.search');
expect(toolNames).toContain('domains.list');
expect(toolNames).toContain('domains.create');
expect(toolNames).toContain('search.query');
expect(toolNames).toContain('activity.list');
});
test('should reject unauthenticated requests', async ({ page }) => {
const response = await page.request.post('/api/mcp', {
data: {
jsonrpc: '2.0',
method: 'server/discover',
id: 1,
},
});
// Without API key, should return 401
expect(response.status()).toBe(401);
});
test('should return error for unknown method', async ({ page }) => {
const response = await page.request.post('/api/mcp', {
data: {
jsonrpc: '2.0',
method: 'unknown.method',
id: 1,
},
});
expect(response.ok()).toBeTruthy();
const body = await response.json();
expect(body.error).toBeDefined();
expect(body.error.code).toBe(-32601); // METHOD_NOT_FOUND
});
test('should reject invalid JSON-RPC request', async ({ page }) => {
const response = await page.request.post('/api/mcp', {
data: { invalid: true },
});
expect(response.status()).toBe(400);
const body = await response.json();
expect(body.error).toBeDefined();
});
test('GET should return 405', async ({ page }) => {
const response = await page.request.get('/api/mcp');
expect(response.status()).toBe(405);
});
});
+35 -28
View File
@@ -6,40 +6,47 @@ test.describe('Navigation', () => {
await login(page); await login(page);
}); });
test('should navigate between all main pages via sidebar', async ({ page }) => { test('every primary route renders its page', async ({ page }) => {
const pages = [ const pages = [
{ href: '/dashboard', name: /dashboard/i }, { url: '/', check: page.getByRole('heading', { name: /dashboard/i }) },
{ href: '/tasks', name: /tasks/i }, { url: '/tasks', check: page.getByRole('heading', { name: /tasks/i }) },
{ href: '/habits', name: /habits/i }, { url: '/habits', check: page.getByRole('heading', { name: /habits/i }) },
{ href: '/projects', name: /projects/i }, { url: '/projects', check: page.getByRole('heading', { name: /projects/i }) },
{ href: '/notes', name: /notes/i }, { url: '/notes', check: page.getByRole('button', { name: /new note/i }) },
{ href: '/graph', name: /graph/i }, { url: '/calendar', check: page.getByRole('heading', { name: /calendar/i }) },
{ href: '/calendar', name: /calendar/i }, { url: '/graph', check: page.getByPlaceholder(/find a node/i) },
{ href: '/search', name: /search/i }, { url: '/search', check: page.getByPlaceholder(/search tasks, notes/i) },
{ url: '/analytics', check: page.getByRole('heading', { name: /analytics/i }) },
{ url: '/agents/activity', check: page.getByRole('heading', { name: /agent activity/i }) },
{ url: '/canvas', check: page.getByRole('heading', { name: /canvas/i }) },
{ url: '/daily', check: page.getByRole('button', { name: /today/i }) },
{ url: '/settings', check: page.getByRole('button', { name: 'Appearance' }) },
]; ];
for (const { href, name } of pages) { for (const { url, check } of pages) {
await page.goto(href); await page.goto(url);
await page.waitForURL(`**${href}`, { timeout: 10_000 }); await page.waitForURL(`**${url}`, { timeout: 10_000 });
await expect(page.locator('h1, h2').filter({ hasText: name }).first()).toBeVisible(); await expect(check).toBeVisible({ timeout: 10_000 });
} }
}); });
test('should open command palette with Cmd+K', async ({ page }) => { test('navigates via the sidebar links', async ({ page }) => {
await page.goto('/dashboard'); // The sidebar is desktop-only; on narrow viewports it lives behind the
await page.keyboard.press('Meta+k'); // topbar menu button.
// Command palette should be visible test.skip((page.viewportSize()?.width ?? 0) < 768, 'sidebar is hidden on mobile');
await expect(page.getByPlaceholder(/type a command/i)).toBeVisible({ timeout: 5_000 });
// Close with Escape
await page.keyboard.press('Escape');
});
test('should open keyboard shortcuts help with ?', async ({ page }) => { const nav = page.getByRole('navigation', { name: 'Primary' });
await page.goto('/dashboard');
await page.keyboard.press('?'); await nav.getByRole('link', { name: 'Tasks' }).click();
// Shortcuts help dialog should be visible await page.waitForURL('**/tasks', { timeout: 10_000 });
await expect(page.getByText(/keyboard shortcuts/i)).toBeVisible({ timeout: 5_000 }); await expect(page.getByRole('heading', { name: /tasks/i })).toBeVisible();
// Close with Escape
await page.keyboard.press('Escape'); await nav.getByRole('link', { name: 'Habits' }).click();
await page.waitForURL('**/habits', { timeout: 10_000 });
await expect(page.getByRole('heading', { name: /habits/i })).toBeVisible();
await nav.getByRole('link', { name: 'Settings' }).click();
await page.waitForURL('**/settings', { timeout: 10_000 });
await expect(page.getByRole('button', { name: 'Appearance' })).toBeVisible();
}); });
}); });
+42 -99
View File
@@ -6,118 +6,61 @@ test.describe('Notes', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await login(page); await login(page);
await page.goto('/notes'); await page.goto('/notes');
// Wait for the notes page to load // The notes page has no h1; the "New Note" button marks it as loaded.
await expect(page.getByRole('heading', { name: /notes/i })).toBeVisible(); await expect(page.getByRole('button', { name: /new note/i })).toBeVisible();
}); });
test.describe('Notes page layout', () => { test('creates a note and opens it in the editor', async ({ page }) => {
test('should display three-panel layout', async ({ page }) => { const createResponse = page.waitForResponse(
// Notes page has: notes list | editor | backlinks/graph (resp) => resp.url().includes('/api/notes') && resp.request().method() === 'POST',
await expect(page.getByText(/connect ideas/i)).toBeVisible(); );
await expect(page.getByRole('button', { name: /new note/i })).toBeVisible(); await page.getByRole('button', { name: /new note/i }).click();
await expect(page.getByRole('button', { name: /daily note/i })).toBeVisible(); expect((await createResponse).ok()).toBeTruthy();
});
test('should show empty state when no notes exist', async ({ page }) => { // The new note is auto-selected and the TipTap editor becomes editable.
const noteItems = page.locator('button[aria-label^="Open note:"]'); await expect(page.locator('.note-editor [contenteditable="true"]')).toBeVisible({
const count = await noteItems.count(); timeout: 10_000,
if (count === 0) {
await expect(page.getByText(/no notes yet/i)).toBeVisible();
}
}); });
}); });
test.describe('Create note', () => { test('typing in the editor autosaves the content', async ({ page }) => {
test('should create a new note when clicking "New note"', async ({ page }) => { // Ensure a note is open first.
// Intercept the API call const createResponse = page.waitForResponse(
const createResponsePromise = page.waitForResponse( (resp) => resp.url().includes('/api/notes') && resp.request().method() === 'POST',
(resp) => resp.url().includes('/api/notes') && resp.request().method() === 'POST', );
); await page.getByRole('button', { name: /new note/i }).click();
await createResponse;
await page.getByRole('button', { name: /new note/i }).click(); const editor = page.locator('.note-editor [contenteditable="true"]');
await expect(editor).toBeVisible({ timeout: 10_000 });
// Wait for the API response // Typing triggers the debounced autosave PATCH.
const response = await createResponsePromise; const patchResponse = page.waitForResponse(
expect(response.ok()).toBeTruthy(); (resp) => /^\/api\/notes\/[^/?]+$/.test(new URL(resp.url()).pathname) &&
resp.request().method() === 'PATCH',
// The new note should appear in the list and be selected );
await page.waitForTimeout(1_000); await editor.fill(testNotes.content);
}); expect((await patchResponse).ok()).toBeTruthy();
}); });
test.describe('Note editor', () => { test('renames a note via the editor title input', async ({ page }) => {
test('should show editor when a note is selected', async ({ page }) => { // Ensure a fresh note is open.
const noteItems = page.locator('button[aria-label^="Open note:"]'); const createResponse = page.waitForResponse(
const count = await noteItems.count(); (resp) => resp.url().includes('/api/notes') && resp.request().method() === 'POST',
);
await page.getByRole('button', { name: /new note/i }).click();
await createResponse;
if (count > 0) { // The new note is titled "Untitled"; rename it via the uncontrolled input.
// Click first note to select it const titleInput = page.locator('input[value="Untitled"]');
await noteItems.first().click(); await expect(titleInput).toBeVisible({ timeout: 10_000 });
// Editor should be visible (title input at minimum) await titleInput.fill(testNotes.title);
await expect(page.getByLabel('Note title')).toBeVisible(); await titleInput.blur();
} else {
// Create a note first
await page.getByRole('button', { name: /new note/i }).click();
await page.waitForTimeout(1_000);
await expect(page.getByLabel('Note title')).toBeVisible();
}
});
test('should update note title when edited', async ({ page }) => { // The updated title shows up in the note list.
// Ensure a note is selected await expect(page.getByText(testNotes.title, { exact: true })).toBeVisible({
const noteItems = page.locator('button[aria-label^="Open note:"]'); timeout: 10_000,
const count = await noteItems.count();
if (count === 0) {
await page.getByRole('button', { name: /new note/i }).click();
await page.waitForTimeout(1_000);
}
const titleInput = page.getByLabel('Note title');
await expect(titleInput).toBeVisible();
// Update the title
await titleInput.clear();
await titleInput.fill(testNotes.title);
// Trigger blur to save
await titleInput.blur();
await page.waitForTimeout(500);
});
});
test.describe('Note backlinks and graph', () => {
test('should show backlinks and graph tabs', async ({ page }) => {
// Right panel should have Backlinks and Graph tabs
await expect(page.getByRole('tab', { name: /backlinks/i })).toBeVisible();
await expect(page.getByRole('tab', { name: /graph/i })).toBeVisible();
});
test('should switch between backlinks and graph views', async ({ page }) => {
// Click graph tab
await page.getByRole('tab', { name: /graph/i }).click();
await expect(page.getByRole('tab', { name: /graph/i })).toHaveAttribute('data-state', 'active');
// Click backlinks tab
await page.getByRole('tab', { name: /backlinks/i }).click();
await expect(page.getByRole('tab', { name: /backlinks/i })).toHaveAttribute('data-state', 'active');
});
});
test.describe('Daily note', () => {
test('should show "Daily note" button', async ({ page }) => {
await expect(page.getByRole('button', { name: /daily note/i })).toBeVisible();
});
test('should create or select daily note when clicking button', async ({ page }) => {
await page.getByRole('button', { name: /daily note/i }).click();
await page.waitForTimeout(1_000);
// After clicking, a note with today's date should be selected
const today = new Date().toLocaleDateString();
// The title might contain the date
}); });
}); });
}); });
+32 -11
View File
@@ -1,5 +1,6 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { login } from './helpers/auth'; import { login } from './helpers/auth';
import { testProjects } from './helpers/fixtures';
test.describe('Projects', () => { test.describe('Projects', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
@@ -8,19 +9,39 @@ test.describe('Projects', () => {
await expect(page.getByRole('heading', { name: /projects/i })).toBeVisible(); await expect(page.getByRole('heading', { name: /projects/i })).toBeVisible();
}); });
test('should display projects page with grid layout', async ({ page }) => { test('creates a project via the New Project dialog', async ({ page }) => {
await expect(page.getByRole('button', { name: /new project/i })).toBeVisible();
});
test('should open new project dialog', async ({ page }) => {
await page.getByRole('button', { name: /new project/i }).click(); await page.getByRole('button', { name: /new project/i }).click();
// Dialog should appear
await page.waitForTimeout(500); const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('heading', { name: 'New Project' })).toBeVisible();
await dialog.getByLabel('Name').fill(testProjects.name);
await dialog.getByLabel('Description').fill(testProjects.description);
await dialog.getByRole('button', { name: /create project/i }).click();
// The dialog closes and the project card appears in the grid.
await expect(dialog).toBeHidden();
await expect(page.getByText(testProjects.name, { exact: true })).toBeVisible({
timeout: 10_000,
});
}); });
test('should show project cards in grid', async ({ page }) => { test('opens the project detail page from a project card', async ({ page }) => {
// The grid container should exist // Ensure the fixture project exists.
const grid = page.locator('.grid, [class*="grid"]').first(); if (!(await page.getByText(testProjects.name, { exact: true }).isVisible().catch(() => false))) {
await expect(grid).toBeVisible(); await page.getByRole('button', { name: /new project/i }).click();
const dialog = page.getByRole('dialog');
await dialog.getByLabel('Name').fill(testProjects.name);
await dialog.getByRole('button', { name: /create project/i }).click();
await expect(page.getByText(testProjects.name, { exact: true })).toBeVisible({ timeout: 10_000 });
}
await page.getByText(testProjects.name, { exact: true }).first().click();
await page.waitForURL('**/projects/*', { timeout: 10_000 });
await expect(
page.getByRole('heading', { name: testProjects.name, level: 3 }),
).toBeVisible({ timeout: 10_000 });
}); });
}); });
-37
View File
@@ -1,37 +0,0 @@
import { test, expect } from '@playwright/test';
import { login } from './helpers/auth';
test.describe('Realtime Updates', () => {
test('should connect to SSE endpoint', async ({ page }) => {
await login(page);
// Navigate to dashboard
await page.goto('/dashboard');
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
// The SSE connection is established automatically via the realtime hook
// Verify the page loaded without errors
const consoleMessages: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error') {
consoleMessages.push(msg.text());
}
});
await page.waitForTimeout(2000);
// Check for SSE-related errors
const sseErrors = consoleMessages.filter(
(m) => m.includes('realtime') || m.includes('SSE') || m.includes('EventSource')
);
expect(sseErrors.length).toBe(0);
});
test('should have realtime API endpoint', async ({ page }) => {
const response = await page.request.get('/api/realtime');
// SSE endpoint should return 200 with text/event-stream content type
expect(response.status()).toBe(200);
const contentType = response.headers()['content-type'] || '';
expect(contentType).toContain('text/event-stream');
});
});
-104
View File
@@ -1,104 +0,0 @@
import { test, expect } from '@playwright/test';
import { login } from './helpers/auth';
import { testReports } from './helpers/fixtures';
test.describe('Reports', () => {
test.beforeEach(async ({ page }) => {
await login(page);
await page.goto('/reports');
// Wait for the reports page to load
await expect(page.getByRole('heading', { name: /reports/i })).toBeVisible();
});
test.describe('Reports page layout', () => {
test('should display two-panel layout', async ({ page }) => {
// Reports page has: reports list | editor
await expect(page.getByText(/step back and see/i)).toBeVisible();
await expect(page.getByRole('button', { name: /new report/i })).toBeVisible();
await expect(page.getByRole('button', { name: /from template/i })).toBeVisible();
});
test('should show empty state when no reports exist', async ({ page }) => {
const reportItems = page.locator('button[aria-label^="Open report:"]');
const count = await reportItems.count();
if (count === 0) {
await expect(page.getByText(/no reports yet/i)).toBeVisible();
}
});
});
test.describe('Create report', () => {
test('should create a new report when clicking "New report"', async ({ page }) => {
const createResponsePromise = page.waitForResponse(
(resp) => resp.url().includes('/api/reports') && resp.request().method() === 'POST',
);
await page.getByRole('button', { name: /new report/i }).click();
const response = await createResponsePromise;
expect(response.ok()).toBeTruthy();
await page.waitForTimeout(1_000);
});
});
test.describe('Report from template', () => {
test('should show templates view when clicking "From template"', async ({ page }) => {
await page.getByRole('button', { name: /from template/i }).click();
await page.waitForTimeout(1_000);
// Should show template options or a templates view
});
});
test.describe('Report editor', () => {
test('should show editor when a report is selected', async ({ page }) => {
const reportItems = page.locator('button[aria-label^="Open report:"]');
const count = await reportItems.count();
if (count === 0) {
// Create a report first
await page.getByRole('button', { name: /new report/i }).click();
await page.waitForTimeout(1_000);
}
// Report title input should be visible
await expect(page.getByLabel('Report title')).toBeVisible();
});
test('should update report title when edited', async ({ page }) => {
const reportItems = page.locator('button[aria-label^="Open report:"]');
const count = await reportItems.count();
if (count === 0) {
await page.getByRole('button', { name: /new report/i }).click();
await page.waitForTimeout(1_000);
}
const titleInput = page.getByLabel('Report title');
await expect(titleInput).toBeVisible();
await titleInput.clear();
await titleInput.fill(testReports.title);
await titleInput.blur();
await page.waitForTimeout(500);
});
test('should show report metadata badges', async ({ page }) => {
const reportItems = page.locator('button[aria-label^="Open report:"]');
const count = await reportItems.count();
if (count > 0) {
await reportItems.first().click();
await page.waitForTimeout(500);
// Should show report type and domain badges
const badges = page.locator('[class*="badge"]');
const badgeCount = await badges.count();
expect(badgeCount).toBeGreaterThan(0);
} else {
test.skip();
}
});
});
});
+17 -10
View File
@@ -4,19 +4,26 @@ import { login } from './helpers/auth';
test.describe('Search', () => { test.describe('Search', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await login(page); await login(page);
await page.goto('/search');
}); });
test('should display search page with search input', async ({ page }) => { test('renders the search page with the query input', async ({ page }) => {
await page.goto('/search'); await expect(page.getByPlaceholder(/search tasks, notes/i)).toBeVisible();
await expect(page.getByRole('heading', { name: /search/i })).toBeVisible(); await expect(page.getByRole('button', { name: 'Tasks' })).toBeVisible();
await expect(page.getByPlaceholder(/search/i).first()).toBeVisible(); await expect(page.getByRole('button', { name: 'Notes' })).toBeVisible();
}); });
test('should perform search and show results', async ({ page }) => { test('shows a no-results state for an unmatched query', async ({ page }) => {
await page.goto('/search'); await page.getByPlaceholder(/search tasks, notes/i).fill('zzz-no-such-thing-xyz');
const searchInput = page.getByPlaceholder(/search/i).first(); await expect(page.getByText(/no results found/i)).toBeVisible({ timeout: 10_000 });
await searchInput.fill('test'); });
// Wait for results
await page.waitForTimeout(1000); test('filters results by type', async ({ page }) => {
await page.getByPlaceholder(/search tasks, notes/i).fill('meeting');
await page.getByRole('button', { name: 'Habits' }).click();
// The filter buttons stay interactive after toggling a type off.
await expect(page.getByRole('button', { name: 'Habits' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Tasks' })).toBeVisible();
}); });
}); });
+59 -157
View File
@@ -6,174 +6,76 @@ test.describe('Settings', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await login(page); await login(page);
await page.goto('/settings'); await page.goto('/settings');
// Wait for the settings page to load
await expect(page.getByRole('heading', { name: /settings/i })).toBeVisible();
}); });
test.describe('Settings page layout', () => { test('renders every settings tab', async ({ page }) => {
test('should display all settings tabs', async ({ page }) => { const tabs = [
// Vertical tabs: Appearance, Domains, Keyboard Shortcuts, Agents, Webhooks, Import & Export, Error Log 'Appearance',
await expect(page.getByRole('tab', { name: /appearance/i })).toBeVisible(); 'Domains',
await expect(page.getByRole('tab', { name: /domains/i })).toBeVisible(); 'Tags',
await expect(page.getByRole('tab', { name: /keyboard shortcuts/i })).toBeVisible(); 'Custom Fields',
await expect(page.getByRole('tab', { name: /agents/i })).toBeVisible(); 'Keyboard Shortcuts',
await expect(page.getByRole('tab', { name: /webhooks/i })).toBeVisible(); 'Agents & Permissions',
await expect(page.getByRole('tab', { name: /import.*export/i })).toBeVisible(); 'Webhooks',
await expect(page.getByRole('tab', { name: /error log/i })).toBeVisible(); 'Import & Export',
'Error Log',
];
for (const tab of tabs) {
await expect(page.getByRole('button', { name: tab })).toBeVisible();
}
});
test('opens on the Appearance tab with theme controls', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Theme' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Accent Color' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Font Size' })).toBeVisible();
});
test('creates a domain from the Domains tab', async ({ page }) => {
await page.getByRole('button', { name: 'Domains' }).click();
await page.getByRole('button', { name: /new domain/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('heading', { name: 'New Domain' })).toBeVisible();
await dialog.getByLabel('Name').fill(testDomains.name);
const createResponse = page.waitForResponse(
(resp) => resp.url().includes('/api/domains') && resp.request().method() === 'POST',
);
await dialog.getByRole('button', { name: /^create$/i }).click();
expect((await createResponse).ok()).toBeTruthy();
await expect(page.getByText(testDomains.name, { exact: true })).toBeVisible({
timeout: 10_000,
}); });
}); });
test.describe('Appearance settings', () => { test('Webhooks tab opens the New Webhook dialog', async ({ page }) => {
test('should show theme mode selector', async ({ page }) => { await page.getByRole('button', { name: 'Webhooks' }).click();
// Appearance tab should be active by default await expect(page.getByRole('button', { name: /new webhook/i })).toBeVisible();
await expect(page.getByLabel('Theme')).toBeVisible();
});
test('should change theme mode', async ({ page }) => { await page.getByRole('button', { name: /new webhook/i }).click();
// Find the theme mode select const dialog = page.getByRole('dialog');
const themeSelect = page.getByLabel('Theme'); await expect(dialog).toBeVisible();
await expect(themeSelect).toBeVisible(); await expect(dialog.getByLabel('Name')).toBeVisible();
await expect(dialog.getByLabel('URL')).toBeVisible();
// Click to open dropdown await expect(dialog.getByLabel(/events/i)).toBeVisible();
await themeSelect.click();
// Should show theme options (light, dark, system)
await expect(page.getByRole('option', { name: /light/i })).toBeVisible();
await expect(page.getByRole('option', { name: /dark/i })).toBeVisible();
await expect(page.getByRole('option', { name: /system/i })).toBeVisible();
});
test('should show accent color picker', async ({ page }) => {
const colorPicker = page.getByRole('radiogroup', { name: /accent color/i });
await expect(colorPicker).toBeVisible();
// Should have color buttons
const colorButtons = colorPicker.getByRole('radio');
const count = await colorButtons.count();
expect(count).toBeGreaterThan(0);
});
test('should change accent color', async ({ page }) => {
const colorPicker = page.getByRole('radiogroup', { name: /accent color/i });
const colorButtons = colorPicker.getByRole('radio');
const count = await colorButtons.count();
if (count > 1) {
// Click a different color
await colorButtons.nth(1).click();
// Should update the accent color
await expect(colorButtons.nth(1)).toHaveAttribute('aria-checked', 'true');
}
});
}); });
test.describe('Domains settings', () => { test('Import & Export tab renders import and export sections', async ({ page }) => {
test('should switch to domains tab', async ({ page }) => { await page.getByRole('button', { name: 'Import & Export' }).click();
await page.getByRole('tab', { name: /domains/i }).click();
await expect(page.getByText(/manage your workspace domains/i)).toBeVisible();
});
test('should add a new domain', async ({ page }) => { await expect(page.getByRole('heading', { name: 'Import' })).toBeVisible();
await page.getByRole('tab', { name: /domains/i }).click(); await expect(page.getByRole('heading', { name: 'Export' })).toBeVisible();
await expect(page.getByRole('button', { name: /download export/i })).toBeVisible();
const domainInput = page.getByPlaceholder(/new domain name/i); await expect(page.getByRole('button', { name: /^import$/i })).toBeVisible();
await expect(domainInput).toBeVisible();
await domainInput.fill(testDomains.name);
// Intercept the API call
const createResponsePromise = page.waitForResponse(
(resp) => resp.url().includes('/api/domains') && resp.request().method() === 'POST',
);
await page.getByRole('button', { name: /add/i }).click();
const response = await createResponsePromise;
expect(response.ok()).toBeTruthy();
// The new domain should appear in the list
await page.waitForTimeout(1_000);
await expect(page.getByText(testDomains.name)).toBeVisible();
});
}); });
test.describe('Keyboard shortcuts settings', () => { test('Error Log tab renders its controls', async ({ page }) => {
test('should show shortcuts list', async ({ page }) => { await page.getByRole('button', { name: 'Error Log' }).click();
await page.getByRole('tab', { name: /keyboard shortcuts/i }).click();
// Should show shortcuts toggle and list await expect(page.getByRole('button', { name: /clear all/i })).toBeVisible();
await expect(page.getByLabel('Enable keyboard shortcuts')).toBeVisible();
await expect(page.getByRole('button', { name: /reset to defaults/i })).toBeVisible();
});
test('should toggle keyboard shortcuts', async ({ page }) => {
await page.getByRole('tab', { name: /keyboard shortcuts/i }).click();
const toggle = page.getByLabel('Enable keyboard shortcuts');
const initialState = await toggle.getAttribute('data-state');
await toggle.click();
await page.waitForTimeout(300);
// State should have changed
const newState = await toggle.getAttribute('data-state');
expect(newState).not.toBe(initialState);
});
});
test.describe('Agents settings', () => {
test('should show agents list and create button', async ({ page }) => {
await page.getByRole('tab', { name: /agents/i }).click();
await expect(page.getByText(/agents.*permissions/i)).toBeVisible();
await expect(page.getByRole('button', { name: /new agent/i })).toBeVisible();
});
test('should open create agent dialog', async ({ page }) => {
await page.getByRole('tab', { name: /agents/i }).click();
await page.getByRole('button', { name: /new agent/i }).click();
// Dialog should open
await expect(page.getByRole('dialog', { name: /create agent/i })).toBeVisible({ timeout: 5_000 });
// Should have form fields
await expect(page.getByLabel('Name')).toBeVisible();
await expect(page.getByLabel('Permission tier')).toBeVisible();
});
});
test.describe('Import & Export settings', () => {
test('should show export and import sections', async ({ page }) => {
await page.getByRole('tab', { name: /import.*export/i }).click();
await expect(page.getByText(/export data/i)).toBeVisible();
await expect(page.getByText(/import data/i)).toBeVisible();
await expect(page.getByRole('button', { name: /export to json/i })).toBeVisible();
await expect(page.getByRole('button', { name: /import from json/i })).toBeVisible();
});
test('should show collection selection for export', async ({ page }) => {
await page.getByRole('tab', { name: /import.*export/i }).click();
// Should have checkboxes for each collection
await expect(page.getByLabel(/select all/i)).toBeVisible();
await expect(page.getByLabel(/export tasks/i)).toBeVisible();
await expect(page.getByLabel(/export habits/i)).toBeVisible();
});
test('should toggle collection selection', async ({ page }) => {
await page.getByRole('tab', { name: /import.*export/i }).click();
const selectAll = page.getByLabel(/select all/i);
await expect(selectAll).toBeVisible();
// Toggle select all off
await selectAll.click();
await page.waitForTimeout(300);
// Tasks checkbox should be unchecked
const tasksCheckbox = page.getByLabel(/export tasks/i);
// The checkbox state should be unchecked now
});
}); });
}); });
+40 -94
View File
@@ -2,117 +2,63 @@ import { test, expect } from '@playwright/test';
import { login } from './helpers/auth'; import { login } from './helpers/auth';
import { testTasks } from './helpers/fixtures'; import { testTasks } from './helpers/fixtures';
test.describe('Task Management', () => { test.describe('Tasks', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await login(page); await login(page);
await page.goto('/tasks'); await page.goto('/tasks');
// Wait for the tasks page to load
await expect(page.getByRole('heading', { name: /tasks/i })).toBeVisible(); await expect(page.getByRole('heading', { name: /tasks/i })).toBeVisible();
}); });
test.describe('Tasks Kanban Board', () => { test('shows the kanban board with the four status columns', async ({ page }) => {
test('should display Kanban board with three columns', async ({ page }) => { await expect(page.getByRole('heading', { name: 'Todo' })).toBeVisible();
// Should show the three Kanban columns await expect(page.getByRole('heading', { name: 'In Progress' })).toBeVisible();
await expect(page.getByRole('heading', { name: /to do/i })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Done' })).toBeVisible();
await expect(page.getByRole('heading', { name: /in progress/i })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Cancelled' })).toBeVisible();
await expect(page.getByRole('heading', { name: /done/i })).toBeVisible(); });
});
test('should switch between Board and List views', async ({ page }) => { test('creates a task via the New Task dialog and shows it on the board', async ({ page }) => {
// Default is Board (Kanban) view await page.getByRole('button', { name: /new task/i }).click();
await expect(page.getByText('Board').first()).toHaveAttribute('data-state', 'active');
// Switch to List view const dialog = page.getByRole('dialog');
await page.getByRole('tab', { name: /list/i }).click(); await expect(dialog).toBeVisible();
await expect(page.getByText('List').first()).toHaveAttribute('data-state', 'active'); await expect(dialog.getByRole('heading', { name: 'New Task' })).toBeVisible();
// Switch back to Board await dialog.getByLabel('Title').fill(testTasks.title);
await page.getByRole('tab', { name: /board/i }).click(); await dialog.getByLabel('Description').fill(testTasks.description);
await expect(page.getByText('Board').first()).toHaveAttribute('data-state', 'active'); await dialog.getByRole('button', { name: /create task/i }).click();
// The dialog closes and the task appears in the Todo column.
await expect(dialog).toBeHidden();
await expect(page.getByText(testTasks.title, { exact: true })).toBeVisible({
timeout: 10_000,
}); });
}); });
test.describe('Task CRUD', () => { test('opens the task detail page when a task card is clicked', async ({ page }) => {
test('should create a new task via detail panel', async ({ page }) => { // Make sure a task exists before trying to open it.
// Intercept the API call if (!(await page.getByText(testTasks.title, { exact: true }).isVisible().catch(() => false))) {
const createResponsePromise = page.waitForResponse( await page.getByRole('button', { name: /new task/i }).click();
(resp) => resp.url().includes('/api/tasks') && resp.request().method() === 'POST', const dialog = page.getByRole('dialog');
); await dialog.getByLabel('Title').fill(testTasks.title);
await dialog.getByRole('button', { name: /create task/i }).click();
await expect(page.getByText(testTasks.title, { exact: true })).toBeVisible({ timeout: 10_000 });
}
// Click on the "To Do" column area to open task creation await page.getByText(testTasks.title, { exact: true }).first().click();
// The app uses a TaskDetailPanel for creation
// We'll look for an existing task or the create mechanism
// The kanban view fetches tasks - let's verify it loaded
await expect(page.getByRole('list', { name: /to do/i })).toBeVisible();
// Find and click a task if one exists, or trigger creation await page.waitForURL('**/tasks/*', { timeout: 10_000 });
// The task detail panel opens when clicking a task
// For creation, the API can be called directly - verify the flow works
const taskCards = page.locator('[role="list"] [class*="cursor-grab"]');
const count = await taskCards.count();
// If tasks exist, click one to open detail panel // The detail page renders the task title as a heading.
if (count > 0) { await expect(
await taskCards.first().click(); page.getByRole('heading', { name: testTasks.title, level: 3 }),
// Detail panel should open ).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5_000 });
}
});
test('should open task detail panel when clicking a task', async ({ page }) => {
// Look for any task card in the kanban board
const taskCards = page.locator('[role="list"] [class*="cursor-grab"]');
const count = await taskCards.count();
if (count > 0) {
await taskCards.first().click();
// Detail panel should appear as a dialog/sheet
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5_000 });
} else {
// No tasks yet – skip gracefully
test.skip();
}
});
}); });
test.describe('Task Drag and Drop', () => { test('switches between board and list views', async ({ page }) => {
test('should have draggable task cards', async ({ page }) => { await page.getByRole('tab', { name: /list view/i }).click();
// Verify draggable elements exist await expect(page.getByRole('columnheader', { name: 'Title' })).toBeVisible();
const draggableTasks = page.locator('[role="list"] [class*="cursor-grab"]');
const count = await draggableTasks.count();
if (count > 0) { await page.getByRole('tab', { name: /board view/i }).click();
// Verify the first task has cursor-grab (indicating draggable) await expect(page.getByRole('heading', { name: 'Todo' })).toBeVisible();
await expect(draggableTasks.first()).toHaveClass(/cursor-grab/);
} else {
test.skip();
}
});
});
test.describe('Task Filtering and Search', () => {
test('should display domain badges on tasks', async ({ page }) => {
// Tasks should show domain badges
const domainBadges = page.locator('[role="list"] [class*="badge"]');
const count = await domainBadges.count();
if (count > 0) {
// At least one badge should be visible
await expect(domainBadges.first()).toBeVisible();
} else {
test.skip();
}
});
});
test.describe('Task List View', () => {
test('should display tasks in list format', async ({ page }) => {
// Switch to list view
await page.getByRole('tab', { name: /list/i }).click();
// The list view should be rendered
// TasksListView component renders tasks in a table or list format
await page.waitForTimeout(500); // Wait for view transition
});
}); });
}); });
+1
View File
@@ -10,6 +10,7 @@
"noEmit": true, "noEmit": true,
"esModuleInterop": true, "esModuleInterop": true,
"isolatedModules": true, "isolatedModules": true,
"types": [],
"skipLibCheck": true, "skipLibCheck": true,
"forceConsistentCasingInFileNames": true "forceConsistentCasingInFileNames": true
}, },
-31
View File
@@ -1,31 +0,0 @@
import { test, expect } from '@playwright/test';
import { login } from './helpers/auth';
test.describe('Webhooks', () => {
test.beforeEach(async ({ page }) => {
await login(page);
});
test('should display webhooks page with create button', async ({ page }) => {
await page.goto('/settings/webhooks');
await page.waitForTimeout(1000);
// The webhooks page should have a create button or heading
const heading = page.getByRole('heading', { name: /webhook/i });
const createBtn = page.getByRole('button', { name: /create|new webhook/i });
// At least one should be visible
await expect(
heading.or(createBtn)
).toBeVisible({ timeout: 5000 });
});
test('should list webhook deliveries endpoint', async ({ page }) => {
const response = await page.request.get('/api/webhook-deliveries?limit=5');
expect(response.ok()).toBeTruthy();
const body = await response.json();
expect(body).toHaveProperty('items');
expect(body).toHaveProperty('totalItems');
expect(Array.isArray(body.items)).toBeTruthy();
});
});
+6 -2
View File
@@ -13,10 +13,14 @@
"dev:web": "cd apps/web && bun run dev", "dev:web": "cd apps/web && bun run dev",
"build": "cd apps/web && bun run build", "build": "cd apps/web && bun run build",
"lint": "echo 'lint: ok'", "lint": "echo 'lint: ok'",
"typecheck": "cd apps/api && tsc --noEmit && cd apps/worker && tsc --noEmit", "typecheck": "cd apps/api && tsc --noEmit && cd ../worker && tsc --noEmit && cd ../web && tsc --noEmit",
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"db:generate": "drizzle-kit generate" "db:generate": "drizzle-kit generate",
"db:sync": "drizzle-kit push --force",
"db:triggers": "bun script/apply-triggers.ts",
"db:migrate": "bun run db:sync && bun run db:triggers",
"deploy": "bash script/deploy.sh"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.61.1", "@playwright/test": "^1.61.1",
+24 -28
View File
@@ -1,11 +1,23 @@
import { defineConfig, devices } from '@playwright/test'; import { defineConfig, devices } from '@playwright/test';
// CI runs chromium only (the runner installs just that browser + deps, keeping
// the pipeline fast). Locally the full 5-browser matrix runs by default.
const CI = !!process.env.CI;
const browserProjects = [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'Mobile Chrome', use: { ...devices['Pixel 5'] } },
{ name: 'Mobile Safari', use: { ...devices['iPhone 12'] } },
];
export default defineConfig({ export default defineConfig({
testDir: './e2e', testDir: './e2e',
fullyParallel: true, fullyParallel: true,
forbidOnly: !!process.env.CI, forbidOnly: CI,
retries: process.env.CI ? 2 : 0, retries: CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined, workers: CI ? 1 : undefined,
reporter: [['html', { open: 'never' }]], reporter: [['html', { open: 'never' }]],
timeout: 30_000, timeout: 30_000,
expect: { expect: {
@@ -17,32 +29,16 @@ export default defineConfig({
screenshot: 'only-on-failure', screenshot: 'only-on-failure',
video: 'on-first-retry', video: 'on-first-retry',
}, },
projects: [ projects: CI
{ ? browserProjects.filter((p) => p.name === 'chromium')
name: 'chromium', : browserProjects,
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
},
],
webServer: { webServer: {
command: 'npm run dev', // `bun run dev` boots the API (:3001) and the Vite dev server (:3000) via
url: 'http://localhost:3000', // concurrently. Poll the API health endpoint through the Vite proxy so we
reuseExistingServer: !process.env.CI, // don't start running tests until both servers are actually up.
command: 'bun run dev',
url: 'http://localhost:3000/api/health',
reuseExistingServer: !CI,
timeout: 120_000, timeout: 120_000,
}, },
}); });
+20
View File
@@ -0,0 +1,20 @@
import { fileURLToPath } from "node:url";
import { sql } from "../db/client";
// Apply the search-vector trigger migration idempotently.
// 0005_search_vector_trigger.sql uses CREATE OR REPLACE FUNCTION, DROP TRIGGER
// IF EXISTS + CREATE TRIGGER, and idempotent backfill UPDATEs, so it is safe
// to run on every deploy.
const triggerFile = fileURLToPath(
new URL("../drizzle/0005_search_vector_trigger.sql", import.meta.url),
);
try {
console.log(`Applying search-vector triggers from ${triggerFile} ...`);
await sql.file(triggerFile);
console.log("Search-vector triggers applied successfully.");
process.exit(0);
} catch (error) {
console.error("Failed to apply search-vector triggers:", error);
process.exit(1);
}
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env bash
# Project E — idempotent deploy script.
#
# Intended to run from the Gitea Actions self-hosted runner (projecte-runner),
# which lives on the same host as the docker-compose stack, but it is also safe
# to run manually from a checkout or from DEPLOY_DIR itself.
#
# Safety guarantees:
# - The host .env is never overwritten (secrets stay on the host).
# - The project-e-pg-data volume is never touched/deleted.
# - Safe to re-run: schema sync (drizzle-kit push --force) and trigger
# application are idempotent, and `docker compose up -d` only recreates
# services whose config/image changed.
set -euo pipefail
DEPLOY_DIR="${DEPLOY_DIR:-/home/projecte/ProjectE}"
echo "=== Project E deploy ==="
echo "Deploy dir: ${DEPLOY_DIR}"
echo "Working dir: $(pwd)"
# ── 1. Sync the CI checkout into DEPLOY_DIR ───────────────────────────────
if [ "$(pwd)" != "${DEPLOY_DIR}" ]; then
echo "Syncing checkout into ${DEPLOY_DIR} ..."
mkdir -p "${DEPLOY_DIR}"
if command -v rsync &>/dev/null; then
rsync -a --delete \
--exclude '.git' \
--exclude 'node_modules' \
--exclude '.next' \
--exclude 'dist' \
--exclude '.turbo' \
--exclude '*.tsbuildinfo' \
--exclude '.env' \
./ "${DEPLOY_DIR}/"
else
echo "rsync not available; falling back to cp -r of needed top-level entries"
cp -r package.json bunfig.toml bun.lock \
apps packages db drizzle script \
Dockerfile.* \
Caddyfile docker-compose.yml \
"${DEPLOY_DIR}/"
fi
else
echo "Already in DEPLOY_DIR; skipping sync"
fi
# ── 2. Operate from the deploy directory ──────────────────────────────────
cd "${DEPLOY_DIR}"
# ── 3. Load secrets from the host .env (docker compose also picks these up) ─
if [ -f .env ]; then
echo "Loading ${DEPLOY_DIR}/.env"
set -a
# shellcheck disable=SC1091
. ./.env
set +a
else
echo "WARNING: no .env found in ${DEPLOY_DIR}; DATABASE_URL may be unset"
fi
# ── 4. Ensure bun ──────────────────────────────────────────────────────────
if ! command -v bun &>/dev/null; then
echo "Installing bun 1.3.14 ..."
npm i -g bun@1.3.14
fi
echo "bun: $(bun --version)"
# ── 5. Install dependencies ────────────────────────────────────────────────
if ! bun install --frozen-lockfile; then
echo "bun install --frozen-lockfile failed; retrying without --frozen-lockfile"
bun install
fi
# ── 6. Database schema + triggers (both idempotent) ───────────────────────
echo "Migrating database schema + triggers (db:migrate) ..."
bun run db:migrate
# ── 7. Build images and start the stack ───────────────────────────────────
echo "Building docker images ..."
docker compose build
echo "Starting stack ..."
docker compose up -d
# ── 8. Wait for API health (up to 60s) ────────────────────────────────────
HEALTH_URL="${HEALTH_URL:-http://localhost:3000/api/health}"
echo "Waiting for API health at ${HEALTH_URL} (max 60s) ..."
healthy=0
for i in $(seq 1 60); do
code="$(curl -s -o /dev/null -w '%{http_code}' "${HEALTH_URL}" 2>/dev/null || true)"
if [ "${code}" = "200" ]; then
healthy=1
break
fi
sleep 1
done
if [ "${healthy}" = "1" ]; then
echo "API healthy after ${i}s (HTTP 200)"
else
echo "ERROR: API did not return HTTP 200 within 60s"
echo "--- recent api logs ---"
docker compose logs --tail=50 api || true
echo "------------------------"
exit 1
fi
# ── 9. Print SPA status ────────────────────────────────────────────────────
echo "SPA HTTP status: $(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/ || true)"
echo "Deploy complete: $(date -u +'%Y-%m-%dT%H:%M:%SZ')"