From 8f55626e03d431b87cfa947dfae84b88d3165f87 Mon Sep 17 00:00:00 2001 From: Matt Batchelder Date: Thu, 16 Jul 2026 06:19:58 -0400 Subject: [PATCH] refactor: migrate to monorepo structure with Docker, PocketBase, and e2e tests - Reorganize into apps/, packages/, docs/, e2e/, pocketbase/ directories - Add Dockerfiles for web, worker, and PocketBase services - Add docker-compose.yml for local orchestration - Add turbo.json for monorepo task management - Add Playwright e2e test infrastructure - Add PocketBase backend with migrations - Remove Vite/Next.js/ESLint/PostCSS config files - Update package.json with workspace dependencies - Add .env.example and .dockerignore --- .dockerignore | 14 + .env.example | 22 + .gitignore | 65 +- Dockerfile.pocketbase | 20 + Dockerfile.web | 36 + Dockerfile.worker | 16 + README.md | 404 +- apps/web/app/(auth)/layout.tsx | 3 + apps/web/app/(auth)/login/page.tsx | 81 + apps/web/app/(dashboard)/agents/page.tsx | 352 + apps/web/app/(dashboard)/analytics/page.tsx | 283 + apps/web/app/(dashboard)/calendar/page.tsx | 285 + apps/web/app/(dashboard)/dashboard/page.tsx | 183 + apps/web/app/(dashboard)/habits/page.tsx | 158 + apps/web/app/(dashboard)/layout.tsx | 27 + apps/web/app/(dashboard)/notes/page.tsx | 351 + .../app/(dashboard)/projects/[id]/page.tsx | 393 + apps/web/app/(dashboard)/projects/page.tsx | 176 + apps/web/app/(dashboard)/reports/page.tsx | 302 + .../(dashboard)/settings/error-log/page.tsx | 128 + apps/web/app/(dashboard)/settings/page.tsx | 101 + apps/web/app/(dashboard)/tasks/page.tsx | 44 + .../app/api/agent-activity/[id]/undo/route.ts | 41 + apps/web/app/api/agent-activity/route.ts | 24 + apps/web/app/api/agent-tasks/route.ts | 24 + apps/web/app/api/agent-webhook/route.ts | 45 + apps/web/app/api/agents/[id]/route.ts | 46 + apps/web/app/api/agents/route.ts | 49 + apps/web/app/api/analytics/route.ts | 62 + apps/web/app/api/analytics/vitals/route.ts | 22 + apps/web/app/api/attachments/upload/route.ts | 40 + apps/web/app/api/auth/login/route.ts | 52 + apps/web/app/api/auth/logout/route.ts | 16 + apps/web/app/api/auth/me/route.ts | 33 + apps/web/app/api/auth/refresh/route.ts | 41 + apps/web/app/api/canvases/[id]/route.ts | 46 + apps/web/app/api/canvases/route.ts | 46 + apps/web/app/api/domains/[id]/route.ts | 46 + apps/web/app/api/domains/route.ts | 46 + apps/web/app/api/error-logs/route.ts | 33 + apps/web/app/api/export/route.ts | 62 + apps/web/app/api/habit-logs/route.ts | 31 + apps/web/app/api/habits/[id]/logs/route.ts | 56 + apps/web/app/api/habits/[id]/route.ts | 46 + apps/web/app/api/habits/route.ts | 53 + apps/web/app/api/habits/streaks/route.ts | 13 + apps/web/app/api/health/route.ts | 9 + apps/web/app/api/import/route.ts | 82 + apps/web/app/api/mcp/route.ts | 127 + apps/web/app/api/milestones/[id]/route.ts | 46 + apps/web/app/api/milestones/route.ts | 53 + .../web/app/api/notes/[id]/backlinks/route.ts | 18 + apps/web/app/api/notes/[id]/route.ts | 58 + apps/web/app/api/notes/daily/route.ts | 236 + apps/web/app/api/notes/graph/route.ts | 13 + apps/web/app/api/notes/route.ts | 61 + .../app/api/projects/[id]/progress/route.ts | 12 + apps/web/app/api/projects/[id]/route.ts | 46 + apps/web/app/api/projects/route.ts | 53 + apps/web/app/api/realtime/route.ts | 111 + apps/web/app/api/reports/[id]/route.ts | 46 + apps/web/app/api/reports/route.ts | 46 + apps/web/app/api/search/route.ts | 54 + apps/web/app/api/tags/[id]/route.ts | 46 + apps/web/app/api/tags/route.ts | 46 + apps/web/app/api/tasks/[id]/route.ts | 46 + apps/web/app/api/tasks/bulk/route.ts | 71 + apps/web/app/api/tasks/route.ts | 53 + apps/web/app/api/time-summary/route.ts | 55 + .../webhook-deliveries/[id]/retry/route.ts | 46 + apps/web/app/api/webhook-deliveries/route.ts | 35 + apps/web/app/api/webhooks/[id]/route.ts | 46 + apps/web/app/api/webhooks/[id]/test/route.ts | 93 + apps/web/app/api/webhooks/route.ts | 46 + apps/web/app/error.tsx | 47 + apps/web/app/globals.css | 332 + apps/web/app/layout.tsx | 45 + apps/web/app/not-found.tsx | 32 + apps/web/app/page.tsx | 5 + apps/web/components.json | 20 + .../components/analytics/analytics-charts.tsx | 345 + .../calendar/big-calendar-wrapper.tsx | 67 + apps/web/components/command-palette.tsx | 205 + .../dashboard/responsive-grid-layout.tsx | 51 + .../widgets/calendar-mini-widget.tsx | 57 + .../widgets/habit-checklist-widget.tsx | 98 + .../widgets/habit-streaks-widget.tsx | 62 + .../widgets/project-progress-widget.tsx | 69 + .../dashboard/widgets/quick-add-widget.tsx | 52 + .../widgets/recent-activity-widget.tsx | 22 + .../dashboard/widgets/today-tasks-widget.tsx | 109 + .../dashboard/widgets/weekly-stats-widget.tsx | 84 + apps/web/components/habits/habit-card.tsx | 97 + .../habits/habit-completion-dialog.tsx | 128 + apps/web/components/habits/habit-heatmap.tsx | 92 + .../keyboard-shortcuts-provider.tsx | 15 + apps/web/components/network-error-banner.tsx | 48 + .../components/notes/daily-note-button.tsx | 104 + apps/web/components/notes/note-editor.tsx | 163 + apps/web/components/notes/note-graph.tsx | 110 + apps/web/components/realtime-provider.tsx | 94 + apps/web/components/reports/report-editor.tsx | 180 + .../components/reports/report-templates.tsx | 284 + .../components/settings/settings-agents.tsx | 189 + .../settings/settings-appearance.tsx | 105 + .../components/settings/settings-domains.tsx | 126 + .../settings/settings-import-export.tsx | 386 + .../settings/settings-shortcuts.tsx | 55 + .../components/settings/settings-webhooks.tsx | 479 + apps/web/components/shortcuts-help.tsx | 81 + apps/web/components/sidebar.tsx | 161 + .../components/tasks/task-detail-panel.tsx | 202 + .../components/tasks/tasks-kanban-view.tsx | 249 + apps/web/components/tasks/tasks-list-view.tsx | 155 + apps/web/components/theme-provider.tsx | 53 + apps/web/components/topbar.tsx | 67 + apps/web/components/ui/accordion.tsx | 57 + apps/web/components/ui/alert-dialog.tsx | 114 + apps/web/components/ui/aspect-ratio.tsx | 8 + apps/web/components/ui/avatar.tsx | 47 + apps/web/components/ui/badge.tsx | 33 + apps/web/components/ui/button.tsx | 56 + apps/web/components/ui/card.tsx | 78 + apps/web/components/ui/checkbox.tsx | 28 + apps/web/components/ui/command.tsx | 150 + apps/web/components/ui/context-menu.tsx | 186 + apps/web/components/ui/dialog.tsx | 101 + apps/web/components/ui/dropdown-menu.tsx | 187 + apps/web/components/ui/hover-card.tsx | 29 + apps/web/components/ui/input.tsx | 22 + apps/web/components/ui/label.tsx | 24 + apps/web/components/ui/menubar.tsx | 215 + apps/web/components/ui/navigation-menu.tsx | 122 + apps/web/components/ui/popover.tsx | 31 + apps/web/components/ui/progress.tsx | 29 + apps/web/components/ui/radio-group.tsx | 40 + apps/web/components/ui/scroll-area.tsx | 46 + apps/web/components/ui/select.tsx | 153 + apps/web/components/ui/separator.tsx | 26 + apps/web/components/ui/sheet.tsx | 137 + apps/web/components/ui/slider.tsx | 25 + apps/web/components/ui/sonner.tsx | 27 + apps/web/components/ui/switch.tsx | 29 + apps/web/components/ui/table.tsx | 120 + apps/web/components/ui/tabs.tsx | 55 + apps/web/components/ui/textarea.tsx | 22 + apps/web/components/ui/toggle-group.tsx | 58 + apps/web/components/ui/toggle.tsx | 44 + apps/web/components/ui/tooltip.tsx | 30 + apps/web/components/web-vitals-tracker.tsx | 12 + apps/web/components/widget-error-boundary.tsx | 62 + apps/web/hooks/use-keyboard-shortcuts.ts | 77 + apps/web/hooks/use-realtime.ts | 127 + apps/web/hooks/use-web-vitals.ts | 104 + apps/web/instrumentation.ts | 12 + apps/web/lib/auth.ts | 137 + apps/web/lib/errors.ts | 105 + apps/web/lib/events/event-bus.ts | 92 + apps/web/lib/mcp/server.ts | 38 + apps/web/lib/mcp/tools/agents.ts | 106 + apps/web/lib/mcp/tools/analytics.ts | 202 + apps/web/lib/mcp/tools/domains.ts | 92 + apps/web/lib/mcp/tools/habits.ts | 184 + apps/web/lib/mcp/tools/milestones.ts | 117 + apps/web/lib/mcp/tools/notes.ts | 141 + apps/web/lib/mcp/tools/projects.ts | 138 + apps/web/lib/mcp/tools/reports.ts | 113 + apps/web/lib/mcp/tools/tags.ts | 86 + apps/web/lib/mcp/tools/tasks.ts | 205 + apps/web/lib/mcp/tools/webhooks.ts | 108 + apps/web/lib/pocketbase.ts | 113 + apps/web/lib/react-grid-layout.ts | 4 + .../web/lib/services/agent-mention-service.ts | 145 + apps/web/lib/services/habit-service.ts | 247 + apps/web/lib/services/index.ts | 7 + apps/web/lib/services/note-service.ts | 307 + apps/web/lib/services/project-service.ts | 140 + apps/web/lib/services/report-service.ts | 269 + apps/web/lib/services/task-service.ts | 176 + apps/web/lib/services/webhook-service.ts | 161 + apps/web/lib/stores/index.ts | 6 + apps/web/lib/stores/use-dashboard-store.ts | 62 + apps/web/lib/stores/use-filter-store.ts | 57 + .../stores/use-keyboard-shortcuts-store.ts | 61 + apps/web/lib/stores/use-sidebar-store.ts | 28 + apps/web/lib/stores/use-theme-store.ts | 41 + apps/web/lib/stores/use-timer-store.ts | 71 + apps/web/lib/theme.ts | 90 + apps/web/lib/utils.ts | 6 + apps/web/middleware.ts | 44 + apps/web/next-env.d.ts | 6 + apps/web/next.config.ts | 113 + apps/web/package.json | 85 + apps/web/postcss.config.mjs | 9 + apps/web/tailwind.config.ts | 76 + apps/web/tsconfig.json | 19 + apps/web/tsconfig.tsbuildinfo | 1 + apps/web/types/react-calendar-heatmap.d.ts | 35 + build/sites-vite-plugin.ts | 45 - docker-compose.yml | 77 + docs/API.md | 1027 ++ docs/ARCHITECTURE.md | 505 + docs/DEPLOYMENT.md | 639 + docs/DEVELOPMENT.md | 729 + docs/MCP.md | 827 + drizzle.config.ts | 7 - e2e/.gitkeep | 0 e2e/analytics.spec.ts | 91 + e2e/auth.spec.ts | 105 + e2e/calendar.spec.ts | 94 + e2e/dashboard.spec.ts | 73 + e2e/habits.spec.ts | 88 + e2e/helpers/auth.ts | 37 + e2e/helpers/fixtures.ts | 49 + e2e/import-export.spec.ts | 152 + e2e/navigation.spec.ts | 172 + e2e/notes.spec.ts | 123 + e2e/projects.spec.ts | 107 + e2e/reports.spec.ts | 104 + e2e/settings.spec.ts | 179 + e2e/tasks.spec.ts | 118 + e2e/tsconfig.json | 18 + eslint.config.mjs | 18 - next.config.ts | 7 - package-lock.json | 13664 ++++++---------- package.json | 56 +- packages/shared/package.json | 21 + packages/shared/src/constants/.gitkeep | 0 packages/shared/src/constants/index.ts | 68 + packages/shared/src/index.ts | 17 + packages/shared/src/schemas/.gitkeep | 0 packages/shared/src/schemas/agent.ts | 96 + packages/shared/src/schemas/canvas.ts | 81 + packages/shared/src/schemas/common.ts | 162 + packages/shared/src/schemas/habit.ts | 90 + packages/shared/src/schemas/milestone.ts | 97 + packages/shared/src/schemas/note.ts | 80 + packages/shared/src/schemas/project.ts | 55 + packages/shared/src/schemas/report.ts | 72 + packages/shared/src/schemas/task.ts | 71 + packages/shared/src/schemas/webhook.ts | 52 + packages/shared/src/types/.gitkeep | 0 packages/shared/src/types/index.ts | 67 + packages/shared/tsconfig.json | 8 + playwright.config.ts | 48 + pocketbase/pb_migrations/.gitkeep | 0 .../1700000000_create_domains.js | 39 + .../pb_migrations/1700000001_create_tags.js | 29 + .../1700000002_create_projects.js | 72 + .../1700000003_create_project_settings.js | 79 + .../1700000004_create_milestones.js | 50 + ...700000005_create_milestone_dependencies.js | 31 + .../1700000006_create_milestone_templates.js | 32 + .../1700000007_create_milestone_history.js | 44 + .../pb_migrations/1700000008_create_tasks.js | 112 + .../1700000009_create_task_subtasks.js | 40 + .../1700000010_create_task_dependencies.js | 31 + .../1700000011_create_task_attachments.js | 45 + .../1700000012_create_task_time_entries.js | 50 + .../pb_migrations/1700000013_create_habits.js | 119 + .../1700000014_create_habit_logs.js | 46 + .../1700000015_create_habit_skip_days.js | 36 + .../pb_migrations/1700000016_create_notes.js | 72 + .../1700000017_create_note_links.js | 36 + .../1700000018_create_note_task_links.js | 36 + .../1700000019_create_report_templates.js | 42 + .../1700000020_create_reports.js | 72 + .../1700000021_create_canvases.js | 39 + .../1700000022_create_canvas_cards.js | 64 + .../pb_migrations/1700000023_create_agents.js | 65 + .../1700000024_create_agent_activity.js | 59 + .../1700000025_create_webhooks.js | 43 + .../1700000026_create_webhook_deliveries.js | 54 + .../1700000027_create_agent_tasks.js | 50 + .../1700000028_create_notifications.js | 49 + .../1700000029_create_error_logs.js | 44 + .../1700000030_create_queue_jobs.js | 66 + pocketbase/schema.ts | 498 + postcss.config.mjs | 7 - tsconfig.json | 34 +- tsconfig.tsbuildinfo | 1 + turbo.json | 28 + vite.config.ts | 59 - worker/index.ts | 372 +- worker/package.json | 19 + worker/tsconfig.json | 15 + 286 files changed, 31992 insertions(+), 9245 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 Dockerfile.pocketbase create mode 100644 Dockerfile.web create mode 100644 Dockerfile.worker create mode 100644 apps/web/app/(auth)/layout.tsx create mode 100644 apps/web/app/(auth)/login/page.tsx create mode 100644 apps/web/app/(dashboard)/agents/page.tsx create mode 100644 apps/web/app/(dashboard)/analytics/page.tsx create mode 100644 apps/web/app/(dashboard)/calendar/page.tsx create mode 100644 apps/web/app/(dashboard)/dashboard/page.tsx create mode 100644 apps/web/app/(dashboard)/habits/page.tsx create mode 100644 apps/web/app/(dashboard)/layout.tsx create mode 100644 apps/web/app/(dashboard)/notes/page.tsx create mode 100644 apps/web/app/(dashboard)/projects/[id]/page.tsx create mode 100644 apps/web/app/(dashboard)/projects/page.tsx create mode 100644 apps/web/app/(dashboard)/reports/page.tsx create mode 100644 apps/web/app/(dashboard)/settings/error-log/page.tsx create mode 100644 apps/web/app/(dashboard)/settings/page.tsx create mode 100644 apps/web/app/(dashboard)/tasks/page.tsx create mode 100644 apps/web/app/api/agent-activity/[id]/undo/route.ts create mode 100644 apps/web/app/api/agent-activity/route.ts create mode 100644 apps/web/app/api/agent-tasks/route.ts create mode 100644 apps/web/app/api/agent-webhook/route.ts create mode 100644 apps/web/app/api/agents/[id]/route.ts create mode 100644 apps/web/app/api/agents/route.ts create mode 100644 apps/web/app/api/analytics/route.ts create mode 100644 apps/web/app/api/analytics/vitals/route.ts create mode 100644 apps/web/app/api/attachments/upload/route.ts create mode 100644 apps/web/app/api/auth/login/route.ts create mode 100644 apps/web/app/api/auth/logout/route.ts create mode 100644 apps/web/app/api/auth/me/route.ts create mode 100644 apps/web/app/api/auth/refresh/route.ts create mode 100644 apps/web/app/api/canvases/[id]/route.ts create mode 100644 apps/web/app/api/canvases/route.ts create mode 100644 apps/web/app/api/domains/[id]/route.ts create mode 100644 apps/web/app/api/domains/route.ts create mode 100644 apps/web/app/api/error-logs/route.ts create mode 100644 apps/web/app/api/export/route.ts create mode 100644 apps/web/app/api/habit-logs/route.ts create mode 100644 apps/web/app/api/habits/[id]/logs/route.ts create mode 100644 apps/web/app/api/habits/[id]/route.ts create mode 100644 apps/web/app/api/habits/route.ts create mode 100644 apps/web/app/api/habits/streaks/route.ts create mode 100644 apps/web/app/api/health/route.ts create mode 100644 apps/web/app/api/import/route.ts create mode 100644 apps/web/app/api/mcp/route.ts create mode 100644 apps/web/app/api/milestones/[id]/route.ts create mode 100644 apps/web/app/api/milestones/route.ts create mode 100644 apps/web/app/api/notes/[id]/backlinks/route.ts create mode 100644 apps/web/app/api/notes/[id]/route.ts create mode 100644 apps/web/app/api/notes/daily/route.ts create mode 100644 apps/web/app/api/notes/graph/route.ts create mode 100644 apps/web/app/api/notes/route.ts create mode 100644 apps/web/app/api/projects/[id]/progress/route.ts create mode 100644 apps/web/app/api/projects/[id]/route.ts create mode 100644 apps/web/app/api/projects/route.ts create mode 100644 apps/web/app/api/realtime/route.ts create mode 100644 apps/web/app/api/reports/[id]/route.ts create mode 100644 apps/web/app/api/reports/route.ts create mode 100644 apps/web/app/api/search/route.ts create mode 100644 apps/web/app/api/tags/[id]/route.ts create mode 100644 apps/web/app/api/tags/route.ts create mode 100644 apps/web/app/api/tasks/[id]/route.ts create mode 100644 apps/web/app/api/tasks/bulk/route.ts create mode 100644 apps/web/app/api/tasks/route.ts create mode 100644 apps/web/app/api/time-summary/route.ts create mode 100644 apps/web/app/api/webhook-deliveries/[id]/retry/route.ts create mode 100644 apps/web/app/api/webhook-deliveries/route.ts create mode 100644 apps/web/app/api/webhooks/[id]/route.ts create mode 100644 apps/web/app/api/webhooks/[id]/test/route.ts create mode 100644 apps/web/app/api/webhooks/route.ts create mode 100644 apps/web/app/error.tsx create mode 100644 apps/web/app/globals.css create mode 100644 apps/web/app/layout.tsx create mode 100644 apps/web/app/not-found.tsx create mode 100644 apps/web/app/page.tsx create mode 100644 apps/web/components.json create mode 100644 apps/web/components/analytics/analytics-charts.tsx create mode 100644 apps/web/components/calendar/big-calendar-wrapper.tsx create mode 100644 apps/web/components/command-palette.tsx create mode 100644 apps/web/components/dashboard/responsive-grid-layout.tsx create mode 100644 apps/web/components/dashboard/widgets/calendar-mini-widget.tsx create mode 100644 apps/web/components/dashboard/widgets/habit-checklist-widget.tsx create mode 100644 apps/web/components/dashboard/widgets/habit-streaks-widget.tsx create mode 100644 apps/web/components/dashboard/widgets/project-progress-widget.tsx create mode 100644 apps/web/components/dashboard/widgets/quick-add-widget.tsx create mode 100644 apps/web/components/dashboard/widgets/recent-activity-widget.tsx create mode 100644 apps/web/components/dashboard/widgets/today-tasks-widget.tsx create mode 100644 apps/web/components/dashboard/widgets/weekly-stats-widget.tsx create mode 100644 apps/web/components/habits/habit-card.tsx create mode 100644 apps/web/components/habits/habit-completion-dialog.tsx create mode 100644 apps/web/components/habits/habit-heatmap.tsx create mode 100644 apps/web/components/keyboard-shortcuts-provider.tsx create mode 100644 apps/web/components/network-error-banner.tsx create mode 100644 apps/web/components/notes/daily-note-button.tsx create mode 100644 apps/web/components/notes/note-editor.tsx create mode 100644 apps/web/components/notes/note-graph.tsx create mode 100644 apps/web/components/realtime-provider.tsx create mode 100644 apps/web/components/reports/report-editor.tsx create mode 100644 apps/web/components/reports/report-templates.tsx create mode 100644 apps/web/components/settings/settings-agents.tsx create mode 100644 apps/web/components/settings/settings-appearance.tsx create mode 100644 apps/web/components/settings/settings-domains.tsx create mode 100644 apps/web/components/settings/settings-import-export.tsx create mode 100644 apps/web/components/settings/settings-shortcuts.tsx create mode 100644 apps/web/components/settings/settings-webhooks.tsx create mode 100644 apps/web/components/shortcuts-help.tsx create mode 100644 apps/web/components/sidebar.tsx create mode 100644 apps/web/components/tasks/task-detail-panel.tsx create mode 100644 apps/web/components/tasks/tasks-kanban-view.tsx create mode 100644 apps/web/components/tasks/tasks-list-view.tsx create mode 100644 apps/web/components/theme-provider.tsx create mode 100644 apps/web/components/topbar.tsx create mode 100644 apps/web/components/ui/accordion.tsx create mode 100644 apps/web/components/ui/alert-dialog.tsx create mode 100644 apps/web/components/ui/aspect-ratio.tsx create mode 100644 apps/web/components/ui/avatar.tsx create mode 100644 apps/web/components/ui/badge.tsx create mode 100644 apps/web/components/ui/button.tsx create mode 100644 apps/web/components/ui/card.tsx create mode 100644 apps/web/components/ui/checkbox.tsx create mode 100644 apps/web/components/ui/command.tsx create mode 100644 apps/web/components/ui/context-menu.tsx create mode 100644 apps/web/components/ui/dialog.tsx create mode 100644 apps/web/components/ui/dropdown-menu.tsx create mode 100644 apps/web/components/ui/hover-card.tsx create mode 100644 apps/web/components/ui/input.tsx create mode 100644 apps/web/components/ui/label.tsx create mode 100644 apps/web/components/ui/menubar.tsx create mode 100644 apps/web/components/ui/navigation-menu.tsx create mode 100644 apps/web/components/ui/popover.tsx create mode 100644 apps/web/components/ui/progress.tsx create mode 100644 apps/web/components/ui/radio-group.tsx create mode 100644 apps/web/components/ui/scroll-area.tsx create mode 100644 apps/web/components/ui/select.tsx create mode 100644 apps/web/components/ui/separator.tsx create mode 100644 apps/web/components/ui/sheet.tsx create mode 100644 apps/web/components/ui/slider.tsx create mode 100644 apps/web/components/ui/sonner.tsx create mode 100644 apps/web/components/ui/switch.tsx create mode 100644 apps/web/components/ui/table.tsx create mode 100644 apps/web/components/ui/tabs.tsx create mode 100644 apps/web/components/ui/textarea.tsx create mode 100644 apps/web/components/ui/toggle-group.tsx create mode 100644 apps/web/components/ui/toggle.tsx create mode 100644 apps/web/components/ui/tooltip.tsx create mode 100644 apps/web/components/web-vitals-tracker.tsx create mode 100644 apps/web/components/widget-error-boundary.tsx create mode 100644 apps/web/hooks/use-keyboard-shortcuts.ts create mode 100644 apps/web/hooks/use-realtime.ts create mode 100644 apps/web/hooks/use-web-vitals.ts create mode 100644 apps/web/instrumentation.ts create mode 100644 apps/web/lib/auth.ts create mode 100644 apps/web/lib/errors.ts create mode 100644 apps/web/lib/events/event-bus.ts create mode 100644 apps/web/lib/mcp/server.ts create mode 100644 apps/web/lib/mcp/tools/agents.ts create mode 100644 apps/web/lib/mcp/tools/analytics.ts create mode 100644 apps/web/lib/mcp/tools/domains.ts create mode 100644 apps/web/lib/mcp/tools/habits.ts create mode 100644 apps/web/lib/mcp/tools/milestones.ts create mode 100644 apps/web/lib/mcp/tools/notes.ts create mode 100644 apps/web/lib/mcp/tools/projects.ts create mode 100644 apps/web/lib/mcp/tools/reports.ts create mode 100644 apps/web/lib/mcp/tools/tags.ts create mode 100644 apps/web/lib/mcp/tools/tasks.ts create mode 100644 apps/web/lib/mcp/tools/webhooks.ts create mode 100644 apps/web/lib/pocketbase.ts create mode 100644 apps/web/lib/react-grid-layout.ts create mode 100644 apps/web/lib/services/agent-mention-service.ts create mode 100644 apps/web/lib/services/habit-service.ts create mode 100644 apps/web/lib/services/index.ts create mode 100644 apps/web/lib/services/note-service.ts create mode 100644 apps/web/lib/services/project-service.ts create mode 100644 apps/web/lib/services/report-service.ts create mode 100644 apps/web/lib/services/task-service.ts create mode 100644 apps/web/lib/services/webhook-service.ts create mode 100644 apps/web/lib/stores/index.ts create mode 100644 apps/web/lib/stores/use-dashboard-store.ts create mode 100644 apps/web/lib/stores/use-filter-store.ts create mode 100644 apps/web/lib/stores/use-keyboard-shortcuts-store.ts create mode 100644 apps/web/lib/stores/use-sidebar-store.ts create mode 100644 apps/web/lib/stores/use-theme-store.ts create mode 100644 apps/web/lib/stores/use-timer-store.ts create mode 100644 apps/web/lib/theme.ts create mode 100644 apps/web/lib/utils.ts create mode 100644 apps/web/middleware.ts create mode 100644 apps/web/next-env.d.ts create mode 100644 apps/web/next.config.ts create mode 100644 apps/web/package.json create mode 100644 apps/web/postcss.config.mjs create mode 100644 apps/web/tailwind.config.ts create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/tsconfig.tsbuildinfo create mode 100644 apps/web/types/react-calendar-heatmap.d.ts delete mode 100644 build/sites-vite-plugin.ts create mode 100644 docker-compose.yml create mode 100644 docs/API.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/DEPLOYMENT.md create mode 100644 docs/DEVELOPMENT.md create mode 100644 docs/MCP.md delete mode 100644 drizzle.config.ts create mode 100644 e2e/.gitkeep create mode 100644 e2e/analytics.spec.ts create mode 100644 e2e/auth.spec.ts create mode 100644 e2e/calendar.spec.ts create mode 100644 e2e/dashboard.spec.ts create mode 100644 e2e/habits.spec.ts create mode 100644 e2e/helpers/auth.ts create mode 100644 e2e/helpers/fixtures.ts create mode 100644 e2e/import-export.spec.ts create mode 100644 e2e/navigation.spec.ts create mode 100644 e2e/notes.spec.ts create mode 100644 e2e/projects.spec.ts create mode 100644 e2e/reports.spec.ts create mode 100644 e2e/settings.spec.ts create mode 100644 e2e/tasks.spec.ts create mode 100644 e2e/tsconfig.json delete mode 100644 eslint.config.mjs delete mode 100644 next.config.ts create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/constants/.gitkeep create mode 100644 packages/shared/src/constants/index.ts create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/schemas/.gitkeep create mode 100644 packages/shared/src/schemas/agent.ts create mode 100644 packages/shared/src/schemas/canvas.ts create mode 100644 packages/shared/src/schemas/common.ts create mode 100644 packages/shared/src/schemas/habit.ts create mode 100644 packages/shared/src/schemas/milestone.ts create mode 100644 packages/shared/src/schemas/note.ts create mode 100644 packages/shared/src/schemas/project.ts create mode 100644 packages/shared/src/schemas/report.ts create mode 100644 packages/shared/src/schemas/task.ts create mode 100644 packages/shared/src/schemas/webhook.ts create mode 100644 packages/shared/src/types/.gitkeep create mode 100644 packages/shared/src/types/index.ts create mode 100644 packages/shared/tsconfig.json create mode 100644 playwright.config.ts create mode 100644 pocketbase/pb_migrations/.gitkeep create mode 100644 pocketbase/pb_migrations/1700000000_create_domains.js create mode 100644 pocketbase/pb_migrations/1700000001_create_tags.js create mode 100644 pocketbase/pb_migrations/1700000002_create_projects.js create mode 100644 pocketbase/pb_migrations/1700000003_create_project_settings.js create mode 100644 pocketbase/pb_migrations/1700000004_create_milestones.js create mode 100644 pocketbase/pb_migrations/1700000005_create_milestone_dependencies.js create mode 100644 pocketbase/pb_migrations/1700000006_create_milestone_templates.js create mode 100644 pocketbase/pb_migrations/1700000007_create_milestone_history.js create mode 100644 pocketbase/pb_migrations/1700000008_create_tasks.js create mode 100644 pocketbase/pb_migrations/1700000009_create_task_subtasks.js create mode 100644 pocketbase/pb_migrations/1700000010_create_task_dependencies.js create mode 100644 pocketbase/pb_migrations/1700000011_create_task_attachments.js create mode 100644 pocketbase/pb_migrations/1700000012_create_task_time_entries.js create mode 100644 pocketbase/pb_migrations/1700000013_create_habits.js create mode 100644 pocketbase/pb_migrations/1700000014_create_habit_logs.js create mode 100644 pocketbase/pb_migrations/1700000015_create_habit_skip_days.js create mode 100644 pocketbase/pb_migrations/1700000016_create_notes.js create mode 100644 pocketbase/pb_migrations/1700000017_create_note_links.js create mode 100644 pocketbase/pb_migrations/1700000018_create_note_task_links.js create mode 100644 pocketbase/pb_migrations/1700000019_create_report_templates.js create mode 100644 pocketbase/pb_migrations/1700000020_create_reports.js create mode 100644 pocketbase/pb_migrations/1700000021_create_canvases.js create mode 100644 pocketbase/pb_migrations/1700000022_create_canvas_cards.js create mode 100644 pocketbase/pb_migrations/1700000023_create_agents.js create mode 100644 pocketbase/pb_migrations/1700000024_create_agent_activity.js create mode 100644 pocketbase/pb_migrations/1700000025_create_webhooks.js create mode 100644 pocketbase/pb_migrations/1700000026_create_webhook_deliveries.js create mode 100644 pocketbase/pb_migrations/1700000027_create_agent_tasks.js create mode 100644 pocketbase/pb_migrations/1700000028_create_notifications.js create mode 100644 pocketbase/pb_migrations/1700000029_create_error_logs.js create mode 100644 pocketbase/pb_migrations/1700000030_create_queue_jobs.js create mode 100644 pocketbase/schema.ts delete mode 100644 postcss.config.mjs create mode 100644 tsconfig.tsbuildinfo create mode 100644 turbo.json delete mode 100644 vite.config.ts create mode 100644 worker/package.json create mode 100644 worker/tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c701f71 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +node_modules +.next +dist +build +.turbo +.git +*.md +.env* +.vscode +.idea +coverage +test-results +playwright-report +pocketbase/pb_data diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8e73ab9 --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# Project E Environment Configuration +# Copy this file to .env and fill in the values + +# PocketBase Configuration +POCKETBASE_URL=http://db:8090 +POCKETBASE_ADMIN_TOKEN=your-secure-random-token-here + +# Application Configuration +NODE_ENV=production + +# Public URL (the domain you access Project E through NPM) +# Example: http://project-e.local or https://project-e.yourdomain.com +# This is used for generating absolute URLs in emails, webhooks, etc. +PUBLIC_URL=http://localhost:3000 + +# Cookie Security +# Set to 'true' if using HTTPS through NPM, 'false' for HTTP-only LAN access +COOKIE_SECURE=false + +# Allowed Hosts (comma-separated list of domains that can access the app) +# Example: project-e.local,project-e.yourdomain.com,localhost +ALLOWED_HOSTS=localhost,project-e.local diff --git a/.gitignore b/.gitignore index 236cfc6..6e3d38d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,42 +1,45 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. +# Dependencies +node_modules/ +.pnp +.pnp.js -# dependencies -/node_modules -/.pnp -.pnp.* -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/versions +# Build outputs +.next/ +dist/ +build/ +.turbo/ -# testing -/coverage +# Environment +.env +.env.local +.env.development.local +.env.test.local +.env.production.local -# next.js -/.next/ -/.vinext/ -/out/ +# Testing +coverage/ +test-results/ +playwright-report/ -# misc +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS .DS_Store -*.pem +Thumbs.db -# debug +# Debug npm-debug.log* yarn-debug.log* yarn-error.log* -.pnpm-debug.log* -# env files (can opt-in for committing if needed) -.env* +# PocketBase +pocketbase/pb_data/ -# vercel -.vercel - -# typescript -next-env.d.ts -/dist/ -/.wrangler/ -/outputs/ -/work/ +# Temp +*.tmp +.wrangler/ +.vinext/ diff --git a/Dockerfile.pocketbase b/Dockerfile.pocketbase new file mode 100644 index 0000000..4b227eb --- /dev/null +++ b/Dockerfile.pocketbase @@ -0,0 +1,20 @@ +FROM alpine:latest + +ARG POCKETBASE_VERSION=0.25.5 + +RUN apk add --no-cache unzip wget ca-certificates + +RUN wget -O /tmp/pocketbase.zip https://github.com/pocketbase/pocketbase/releases/download/v${POCKETBASE_VERSION}/pocketbase_${POCKETBASE_VERSION}_linux_amd64.zip \ + && unzip /tmp/pocketbase.zip -d /usr/local/bin/ \ + && rm /tmp/pocketbase.zip \ + && chmod +x /usr/local/bin/pocketbase + +# Copy migrations +COPY pocketbase/pb_migrations/ /pb_migrations/ + +EXPOSE 8090 + +HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=10s \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8090/api/health || exit 1 + +ENTRYPOINT ["/usr/local/bin/pocketbase", "serve", "--http=0.0.0.0:8090", "--dir=/pb_data", "--publicDir=/pb_public", "--migrationDir=/pb_migrations"] diff --git a/Dockerfile.web b/Dockerfile.web new file mode 100644 index 0000000..64afee4 --- /dev/null +++ b/Dockerfile.web @@ -0,0 +1,36 @@ +# Stage 1: Install dependencies +FROM node:22-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +COPY apps/web/package.json apps/web/package.json +COPY packages/shared/package.json packages/shared/package.json +RUN npm ci + +# Stage 2: Build the application +FROM node:22-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules +COPY . . +ENV NEXT_TELEMETRY_DISABLED=1 +ENV NODE_ENV=production +RUN npm run build --workspace=apps/web + +# Stage 3: Production runtime +FROM node:22-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/apps/web/public ./apps/web/public +COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static + +USER nextjs +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" +CMD ["node", "apps/web/server.js"] diff --git a/Dockerfile.worker b/Dockerfile.worker new file mode 100644 index 0000000..2e3d8e9 --- /dev/null +++ b/Dockerfile.worker @@ -0,0 +1,16 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY package.json package-lock.json ./ +COPY worker/package.json worker/package.json +COPY packages/shared/package.json packages/shared/package.json + +RUN npm ci --omit=dev + +COPY worker/ ./worker/ +COPY packages/shared/ ./packages/shared/ + +ENV NODE_ENV=production + +CMD ["node", "worker/index.js"] diff --git a/README.md b/README.md index b02db8a..7619bdd 100644 --- a/README.md +++ b/README.md @@ -1,98 +1,352 @@ -# vinext-starter +# Project E -A clean full-stack starter running on -[vinext](https://github.com/cloudflare/vinext), with optional Cloudflare D1 and -Drizzle support. +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. + +## Features + +- **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 +- **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 +- **Reports:** Weekly, monthly, project, and habit reports with templates and AI-assisted generation +- **Milestones:** Plan project phases, set dependencies, and track completion +- **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 +- **Webhooks:** Subscribe to events, deliver payloads with HMAC signatures, and track delivery history +- **Analytics:** Task completion rates, habit consistency, time summaries, and streak tracking +- **Realtime:** Server-sent events proxy keeps the UI in sync across devices +- **Background Worker:** Processes webhook deliveries, agent mentions, report generation, recurring tasks, and data cleanup +- **MCP Server:** 61 tools for AI agents to read and write data through the Model Context Protocol + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Frontend (Next.js) │ +│ React 19 · App Router · shadcn/ui · Tailwind · Zustand │ +└──────────────────────────┬──────────────────────────────────┘ + │ REST API + SSE +┌──────────────────────────▼──────────────────────────────────┐ +│ API Layer (Next.js Routes) │ +│ Auth · Validation (Zod) · Realtime SSE Proxy · MCP Server │ +└──────────────────────────┬──────────────────────────────────┘ + │ PocketBase SDK +┌──────────────────────────▼──────────────────────────────────┐ +│ Data Layer (PocketBase) │ +│ SQLite · Auth · Realtime · File Storage · Admin UI │ +└─────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────┐ +│ Background Worker │ +│ Webhook Delivery · Agent Mentions · Report Generation │ +│ Recurring Tasks · Data Cleanup │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Tech Stack + +| Layer | Technology | +|-------|-----------| +| Frontend | Next.js 15, React 19, TypeScript 5.9 | +| UI Components | shadcn/ui, Radix UI, Lucide icons | +| Styling | Tailwind CSS 3.4, tailwind-merge, class-variance-authority | +| State Management | Zustand 5 (UI), PocketBase realtime (data) | +| Rich Text | Tiptap 3 | +| Forms | React Hook Form 7, Zod 4 validation | +| Calendar | react-big-calendar, date-fns | +| Charts | Recharts 3 | +| Graph Visualization | react-force-graph-2d | +| Drag & Drop | @dnd-kit | +| Backend | Next.js API routes (App Router) | +| Database | PocketBase 0.25 (SQLite) | +| Background Jobs | Node.js worker with polling and exponential backoff | +| MCP Server | @modelcontextprotocol/sdk 1.29 | +| Monorepo | Turborepo 2.5, npm workspaces | +| Testing | Jest (unit/component), Playwright 1.61 (E2E) | +| Deployment | Docker Compose (3 containers) | ## Prerequisites -- Node.js `>=22.13.0` +- **Node.js** 22.13.0 or later +- **npm** 10.0.0 or later +- **Docker** and Docker Compose (for production deployment) +- **PocketBase** 0.25.5 (included in Docker setup) ## Quick Start +### Development Setup + +1. **Clone the repository** + + ```bash + git clone + cd ProjectE + ``` + +2. **Install dependencies** + + ```bash + npm install + ``` + +3. **Start PocketBase** (in a separate terminal) + + ```bash + # Download PocketBase if you haven't already + # https://pocketbase.io/docs/ + + # Start PocketBase with migrations + pocketbase serve --dir=./pb_data --publicDir=./pb_public --migrationDir=./pocketbase/pb_migrations + ``` + + Or use Docker: + + ```bash + docker compose up db -d + ``` + +4. **Set environment variables** + + Create a `.env.local` file in the root: + + ```bash + POCKETBASE_URL=http://localhost:8090 + POCKETBASE_ADMIN_TOKEN=your_admin_token_here + ``` + + Get the admin token from PocketBase after creating your first admin account. + +5. **Start the development server** + + ```bash + npm run dev + ``` + + This starts all packages via Turborepo: + - Web app at `http://localhost:3000` + - PocketBase at `http://localhost:8090` + - Worker (if configured) + +6. **Create your first user** + + Open `http://localhost:3000` and sign up, or use the PocketBase admin UI at `http://localhost:8090/_/` to create users. + +### Production Deployment (Docker) + ```bash -npm install -npm run dev -npm run build +# Build and start all containers +docker compose up -d + +# Check status +docker compose ps + +# View logs +docker compose logs -f + +# Stop all containers +docker compose down ``` -This starter does not use `wrangler.jsonc`. +The deployment starts three containers: +- **web:** Next.js app on port 3000 +- **db:** PocketBase on port 8090 +- **worker:** Background job processor -## Included Shape +## Project Structure -- edit site code under `app/` -- `.openai/hosting.json` declares optional Sites D1 and R2 bindings -- `vite.config.ts` simulates declared bindings for local development -- `db/schema.ts` starts intentionally empty -- `examples/d1/` contains an optional D1 example surface -- `drizzle.config.ts` supports local migration generation when needed - -## Workspace Auth Headers - -OpenAI workspace sites can read the current user's email from -`oai-authenticated-user-email`. - -SIWC-authenticated workspace sites may also receive -`oai-authenticated-user-full-name` when the user's SIWC profile has a non-empty -`name` claim. The full-name value is percent-encoded UTF-8 and is accompanied by -`oai-authenticated-user-full-name-encoding: percent-encoded-utf-8`. - -Treat the full name as optional and fall back to email when it is absent: - -```tsx -import { headers } from "next/headers"; - -export default async function Home() { - const requestHeaders = await headers(); - const email = requestHeaders.get("oai-authenticated-user-email"); - const encodedFullName = requestHeaders.get("oai-authenticated-user-full-name"); - const fullName = - encodedFullName && - requestHeaders.get("oai-authenticated-user-full-name-encoding") === - "percent-encoded-utf-8" - ? decodeURIComponent(encodedFullName) - : null; - - const displayName = fullName ?? email; - // ... -} +``` +project-e/ +├── apps/ +│ └── web/ # Next.js application +│ ├── app/ # App Router pages and API routes +│ │ ├── (auth)/ # Auth pages (login, signup) +│ │ ├── (dashboard)/ # Dashboard pages +│ │ ├── 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 proxy for PocketBase +│ │ │ ├── mcp/ # MCP server endpoint +│ │ │ └── health/ # Health check +│ │ └── layout.tsx # Root layout +│ ├── components/ # React components (shadcn/ui) +│ ├── hooks/ # Custom React hooks +│ ├── lib/ # Utilities and services +│ │ ├── mcp/ # MCP server and tools +│ │ ├── services/ # Business logic services +│ │ ├── stores/ # Zustand stores +│ │ ├── events/ # Event bus +│ │ ├── auth.ts # Auth middleware +│ │ ├── pocketbase.ts # PocketBase client +│ │ └── errors.ts # Error handling +│ └── types/ # TypeScript type definitions +├── packages/ +│ └── shared/ # Shared package +│ └── src/ +│ ├── schemas/ # Zod validation schemas +│ ├── types/ # TypeScript types +│ └── constants/ # Shared constants +├── pocketbase/ +│ ├── pb_migrations/ # Database migrations +│ └── schema.ts # TypeScript types for collections +├── worker/ +│ └── index.ts # Background job worker +├── e2e/ # Playwright E2E tests +├── tests/ # Unit and component tests +├── docker-compose.yml # Docker Compose configuration +├── Dockerfile.web # Web container build +├── Dockerfile.pocketbase # PocketBase container build +├── Dockerfile.worker # Worker container build +├── turbo.json # Turborepo configuration +└── package.json # Root package.json ``` -## Optional Dispatch-Owned ChatGPT Sign-In +## Available Scripts -Import the ready-to-use helpers from `app/chatgpt-auth.ts` when the site needs -optional or required ChatGPT sign-in: +| Command | Description | +|---------|-------------| +| `npm run dev` | Start all packages in development mode | +| `npm run build` | Build all packages for production | +| `npm run lint` | Run linting across all packages | +| `npm run test` | Run unit and component tests (Jest) | +| `npm run test:e2e` | Run Playwright E2E tests | +| `npm run test:e2e:ui` | Run Playwright tests with UI mode | +| `npm run test:e2e:report` | Show Playwright test report | +| `npm run typecheck` | Run TypeScript type checking | +| `npm run db:generate` | Generate database types | -- Use `getChatGPTUser()` for optional signed-in UI. -- Use `requireChatGPTUser(returnTo)` for server-rendered pages that should send - anonymous visitors through Sign in with ChatGPT. -- Use `chatGPTSignInPath(returnTo)` and `chatGPTSignOutPath(returnTo)` for - browser links or actions. -- Pass a same-origin relative `returnTo` path for the destination after sign-in - or sign-out. The helper validates and safely encodes it. -- Mark protected pages with `export const dynamic = "force-dynamic"` because - they depend on per-request identity headers. +## Environment Variables -Dispatch owns `/signin-with-chatgpt`, `/signout-with-chatgpt`, `/callback`, the -OAuth cookies, and identity header injection. Do not implement app routes for -those reserved paths. Routes that do not import and call the helper remain -anonymous-compatible. +| Variable | Description | Default | +|----------|-------------|---------| +| `POCKETBASE_URL` | PocketBase server URL | `http://localhost:8090` | +| `POCKETBASE_ADMIN_TOKEN` | Admin authentication token | (required for worker) | +| `NODE_ENV` | Environment (`development`, `production`) | `development` | -SIWC establishes identity only; it does not prove workspace membership. Use the -Sites hosting platform's access policy controls for workspace-wide restrictions, -or enforce explicit server-side membership or allowlist checks. +Create a `.env.local` file in the root directory for local development. -Use SIWC for account pages, user-specific dashboards, saved records, and write -actions tied to the current ChatGPT user. Leave public content anonymous. +## Testing -## Useful Commands +### Unit and Component Tests -- `npm run dev`: start local development -- `npm run build`: verify the vinext build output -- `npm test`: build the starter and verify its rendered loading skeleton -- `npm run db:generate`: generate Drizzle migrations after schema changes +```bash +npm run test +``` -## Learn More +Runs Jest tests across all packages. Tests are located in `tests/` and alongside components. -- [vinext Documentation](https://github.com/cloudflare/vinext) -- [Drizzle D1 Guide](https://orm.drizzle.team/docs/get-started/d1-new) +### 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 + +### Docker Compose (Recommended) + +```bash +# Build and start +docker compose up -d + +# View logs +docker compose logs -f + +# Stop +docker compose down + +# Rebuild after code changes +docker compose up -d --build +``` + +### Environment Configuration + +Set these in your deployment environment: + +```bash +POCKETBASE_URL=http://db:8090 +POCKETBASE_ADMIN_TOKEN=your_secure_admin_token +``` + +### Volumes + +- `project-e-pb-data`: PocketBase database files +- `project-e-web-uploads`: Uploaded files + +### Health Checks + +All containers include health checks: +- Web: `GET /api/health` +- PocketBase: `GET /api/health` + +## Documentation + +- [API Documentation](docs/API.md): REST API endpoints, authentication, error handling +- [MCP Server Documentation](docs/MCP.md): Model Context Protocol tools and usage +- [Deployment Guide](docs/DEPLOYMENT.md): Production deployment, SSL, backups, monitoring +- [Development Guide](docs/DEVELOPMENT.md): Contributing, code structure, testing strategy +- [Architecture Documentation](docs/ARCHITECTURE.md): System design, data flow, security + +## Contributing + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Make your changes +4. Run tests (`npm run test && npm run test:e2e`) +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 + +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 diff --git a/apps/web/app/(auth)/layout.tsx b/apps/web/app/(auth)/layout.tsx new file mode 100644 index 0000000..9143d65 --- /dev/null +++ b/apps/web/app/(auth)/layout.tsx @@ -0,0 +1,3 @@ +export default function AuthLayout({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx new file mode 100644 index 0000000..8ed6a04 --- /dev/null +++ b/apps/web/app/(auth)/login/page.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { handleApiError } from '@/lib/errors'; + +export default function LoginPage() { + const router = useRouter(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + + try { + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + + if (!response.ok) { + const error = await response.json(); + throw error; + } + + router.push('/dashboard'); + } catch (error) { + handleApiError(error, 'Login failed'); + } finally { + setLoading(false); + } + } + + return ( +
+ + + Project E + Sign in to your workspace + +
+ +
+ + setEmail(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+
+ + + +
+
+
+ ); +} diff --git a/apps/web/app/(dashboard)/agents/page.tsx b/apps/web/app/(dashboard)/agents/page.tsx new file mode 100644 index 0000000..73d1ecb --- /dev/null +++ b/apps/web/app/(dashboard)/agents/page.tsx @@ -0,0 +1,352 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Activity, CheckCircle2, XCircle, Clock, RotateCcw } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Avatar, AvatarFallback } from '@/components/ui/avatar'; + +interface Agent { + id: string; + name: string; + avatar?: string; + description?: string; + permission_tier: string; + status: 'active' | 'disabled'; + last_activity_at?: string; +} + +interface AgentActivity { + id: string; + agent_id: string; + action: string; + entity_type: string; + entity_id: string; + before_state?: Record; + after_state?: Record; + created: string; +} + +interface AgentTask { + id: string; + agent_id: string; + task_type: string; + input: string; + status: 'pending' | 'in_progress' | 'completed' | 'failed'; + output?: Record; + created: string; +} + +export default function AgentsPage() { + const [agents, setAgents] = useState([]); + const [activity, setActivity] = useState([]); + const [agentTasks, setAgentTasks] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedAgent, setSelectedAgent] = useState(null); + + useEffect(() => { + fetchAgents(); + fetchActivity(); + fetchAgentTasks(); + }, []); + + async function fetchAgents() { + try { + const response = await fetch('/api/agents'); + if (response.ok) { + const data = await response.json(); + setAgents(data.items || []); + } + } catch (error) { + console.error('Failed to fetch agents:', error); + } finally { + setLoading(false); + } + } + + async function fetchActivity() { + try { + const response = await fetch('/api/agent-activity?sort=-created&perPage=50'); + if (response.ok) { + const data = await response.json(); + setActivity(data.items || []); + } + } catch (error) { + console.error('Failed to fetch activity:', error); + } + } + + async function fetchAgentTasks() { + try { + const response = await fetch('/api/agent-tasks?sort=-created&perPage=50'); + if (response.ok) { + const data = await response.json(); + setAgentTasks(data.items || []); + } + } catch (error) { + console.error('Failed to fetch agent tasks:', error); + } + } + + async function undoActivity(activityId: string) { + try { + await fetch(`/api/agent-activity/${activityId}/undo`, { + method: 'POST', + }); + fetchActivity(); + } catch (error) { + console.error('Failed to undo activity:', error); + } + } + + function getAgentName(agentId: string): string { + const agent = agents.find((a) => a.id === agentId); + return agent?.name || 'Unknown Agent'; + } + + function getStatusIcon(status: string) { + switch (status) { + case 'completed': + return ; + case 'failed': + return ; + case 'in_progress': + return ; + default: + return ; + } + } + + function getActionLabel(action: string): string { + const labels: Record = { + create: 'Created', + update: 'Updated', + delete: 'Deleted', + complete: 'Completed', + assign: 'Assigned', + }; + return labels[action] || action; + } + + if (loading) { + return

Loading agent activity...

; + } + + return ( +
+
+

Agent Activity

+

+ Every agent action, visible and reversible. +

+
+ +
+ {/* Agents list */} + + + Agents + + + {agents.length === 0 ? ( +

+ No agents configured +

+ ) : ( +
+ {agents.map((agent) => ( + + ))} +
+ )} +
+
+ + {/* Activity feed */} + + + + + + + + + Activity + Tasks + + + + {activity.length === 0 ? ( +

+ No agent activity yet +

+ ) : ( +
+ {activity.map((item) => ( +
+
+
+ + + {getAgentName(item.agent_id).charAt(0).toUpperCase()} + + +
+
+ + {getAgentName(item.agent_id)} + + + {getActionLabel(item.action)} + + + {item.entity_type} + +
+

+ {new Date(item.created).toLocaleString()} +

+ {item.before_state && item.after_state && ( +
+ + View changes + +
+
+

Before

+
+                                        {JSON.stringify(item.before_state, null, 2)}
+                                      
+
+
+

After

+
+                                        {JSON.stringify(item.after_state, null, 2)}
+                                      
+
+
+
+ )} +
+
+ +
+
+ ))} +
+ )} +
+ + + {agentTasks.length === 0 ? ( +

+ No agent tasks yet +

+ ) : ( +
+ {agentTasks.map((task) => ( +
+
+ {getStatusIcon(task.status)} +
+
+ + {getAgentName(task.agent_id)} + + + {task.status} + +
+

{task.input}

+

+ {new Date(task.created).toLocaleString()} +

+ {task.output && ( +
+ + View output + +
+                                  {JSON.stringify(task.output, null, 2)}
+                                
+
+ )} +
+
+
+ ))} +
+ )} +
+
+
+
+
+
+ ); +} diff --git a/apps/web/app/(dashboard)/analytics/page.tsx b/apps/web/app/(dashboard)/analytics/page.tsx new file mode 100644 index 0000000..d774b63 --- /dev/null +++ b/apps/web/app/(dashboard)/analytics/page.tsx @@ -0,0 +1,283 @@ +'use client'; + +import { useEffect, useState, Suspense } from 'react'; +import { + TrendingUp, + Target, + Clock, + Flame, + BarChart3, + PieChart as PieChartIcon, +} from 'lucide-react'; +import dynamic from 'next/dynamic'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@/components/ui/tabs'; + +// Lazy load recharts (~180KB) +const AnalyticsCharts = dynamic( + () => import('@/components/analytics/analytics-charts').then((m) => m.AnalyticsCharts), + { + ssr: false, + loading: () => ( +
+ {[1, 2].map((i) => ( +
+
+
+
+ ))} +
+ ), + } +); + +interface AnalyticsData { + taskCompletionRate: number; + habitConsistency: number; + totalTimeMinutes: number; + activeStreaks: number; + bestStreak: number; + period: number; +} + +interface TimeData { + date: string; + tasks: number; + habits: number; + time: number; +} + +interface DomainData { + name: string; + value: number; + color: string; +} + +interface HabitData { + name: string; + streak: number; + score: number; + consistency: number; +} + +export default function AnalyticsPage() { + const [analytics, setAnalytics] = useState(null); + const [timeData, setTimeData] = useState([]); + const [domainData, setDomainData] = useState([]); + const [habitData, setHabitData] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchAnalytics(); + }, []); + + async function fetchAnalytics() { + try { + // Fetch overall analytics + const analyticsResponse = await fetch('/api/analytics?period=30'); + if (analyticsResponse.ok) { + const analyticsData = await analyticsResponse.json(); + setAnalytics(analyticsData); + } + + // Fetch time summary + const timeResponse = await fetch('/api/time-summary?period=30'); + if (timeResponse.ok) { + const timeSummary = await timeResponse.json(); + + // Transform to domain data for pie chart + const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#06b6d4']; + const domains: DomainData[] = Object.entries( + timeSummary.byDomain || {} + ).map(([name, value], index) => ({ + name, + value: value as number, + color: COLORS[index % COLORS.length], + })); + setDomainData(domains); + } + + // Fetch habit streaks + const habitsResponse = await fetch('/api/habits/streaks'); + if (habitsResponse.ok) { + const habitsData = await habitsResponse.json(); + const habits: HabitData[] = (habitsData.streaks || []).map( + (s: { + habit: { name: string; score?: number }; + current_streak: number; + best_streak: number; + }) => ({ + name: s.habit.name, + streak: s.current_streak, + score: s.habit.score || 0, + consistency: 0, // Would need to calculate from logs + }) + ); + setHabitData(habits); + } + + // Generate sample time data (would come from API in production) + const sampleTimeData: TimeData[] = Array.from({ length: 30 }, (_, i) => { + const date = new Date(); + date.setDate(date.getDate() - (29 - i)); + return { + date: date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }), + tasks: Math.floor(Math.random() * 10) + 2, + habits: Math.floor(Math.random() * 5) + 1, + time: Math.floor(Math.random() * 180) + 30, + }; + }); + setTimeData(sampleTimeData); + } catch (error) { + console.error('Failed to fetch analytics:', error); + } finally { + setLoading(false); + } + } + + if (loading || !analytics) { + return

Loading analytics...

; + } + + return ( +
+
+

Analytics

+

Patterns behind your progress.

+
+ + {/* Summary cards */} +
+ + + + Task Completion + + + +
+ {analytics.taskCompletionRate}% +
+

+ Last {analytics.period} days +

+
+
+ + + + + Habit Consistency + + + +
+ {analytics.habitConsistency}% +
+

+ Last {analytics.period} days +

+
+
+ + + + Time Tracked + + +
+ {Math.round(analytics.totalTimeMinutes / 60)}h +
+

+ Last {analytics.period} days +

+
+
+ + + + + Active Streaks + + + +
{analytics.activeStreaks}
+

+ Best: {analytics.bestStreak} days +

+
+
+
+ + {/* Charts */} + + + Trends + Habits + Time + + + + + {[1, 2].map((i) => ( +
+ ))} +
+ } + > + +
+
+ + + + } + > + + + + + + + } + > + + + +
+
+ ); +} diff --git a/apps/web/app/(dashboard)/calendar/page.tsx b/apps/web/app/(dashboard)/calendar/page.tsx new file mode 100644 index 0000000..527d600 --- /dev/null +++ b/apps/web/app/(dashboard)/calendar/page.tsx @@ -0,0 +1,285 @@ +'use client'; + +import { useEffect, useState, useMemo, Suspense } from 'react'; +import { Filter } from 'lucide-react'; +import dynamic from 'next/dynamic'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Label } from '@/components/ui/label'; + +// Lazy load react-big-calendar (~60KB + date-fns) +const BigCalendar = dynamic( + () => import('@/components/calendar/big-calendar-wrapper').then((m) => m.BigCalendarWrapper), + { + ssr: false, + loading: () => ( +
+
Loading calendar...
+
+ ), + } +); + +interface CalendarEvent { + id: string; + title: string; + start: Date; + end: Date; + type: 'task' | 'habit' | 'project' | 'milestone'; + domain: string; + color: string; +} + +export default function CalendarPage() { + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [showTasks, setShowTasks] = useState(true); + const [showHabits, setShowHabits] = useState(true); + const [showProjects, setShowProjects] = useState(true); + const [showMilestones, setShowMilestones] = useState(true); + const [selectedDomains, setSelectedDomains] = useState([]); + + useEffect(() => { + fetchEvents(); + }, []); + + async function fetchEvents() { + try { + // Fetch tasks with due dates + const tasksResponse = await fetch('/api/tasks?filter=due_date!%3D%22%22&perPage=500'); + const tasksData = tasksResponse.ok ? await tasksResponse.json() : { items: [] }; + + // Fetch projects with target dates + const projectsResponse = await fetch('/api/projects?filter=target_date!%3D%22%22&perPage=500'); + const projectsData = projectsResponse.ok ? await projectsResponse.json() : { items: [] }; + + // Fetch milestones with target dates + const milestonesResponse = await fetch('/api/milestones?filter=target_date!%3D%22%22&perPage=500'); + const milestonesData = milestonesResponse.ok ? await milestonesResponse.json() : { items: [] }; + + const calendarEvents: CalendarEvent[] = []; + + // Add tasks + if (tasksData.items) { + for (const task of tasksData.items) { + if (task.due_date) { + const date = new Date(task.due_date); + calendarEvents.push({ + id: `task-${task.id}`, + title: task.title, + start: date, + end: date, + type: 'task', + domain: task.domain ?? 'personal', + color: '#3b82f6', // blue + }); + } + } + } + + // Add projects + if (projectsData.items) { + for (const project of projectsData.items) { + if (project.target_date) { + const date = new Date(project.target_date); + calendarEvents.push({ + id: `project-${project.id}`, + title: `📁 ${project.name}`, + start: date, + end: date, + type: 'project', + domain: project.domain ?? 'personal', + color: '#8b5cf6', // purple + }); + } + } + } + + // Add milestones + if (milestonesData.items) { + for (const milestone of milestonesData.items) { + if (milestone.target_date) { + const date = new Date(milestone.target_date); + calendarEvents.push({ + id: `milestone-${milestone.id}`, + title: `🎯 ${milestone.name}`, + start: date, + end: date, + type: 'milestone', + domain: milestone.domain ?? 'work', + color: '#f59e0b', // amber + }); + } + } + } + + setEvents(calendarEvents); + } catch (error) { + console.error('Failed to fetch calendar events:', error); + } finally { + setLoading(false); + } + } + + const filteredEvents = useMemo(() => { + return events.filter((event) => { + // Filter by type + if (event.type === 'task' && !showTasks) return false; + if (event.type === 'habit' && !showHabits) return false; + if (event.type === 'project' && !showProjects) return false; + if (event.type === 'milestone' && !showMilestones) return false; + + // Filter by domain + if (selectedDomains.length > 0 && !selectedDomains.includes(event.domain)) { + return false; + } + + return true; + }); + }, [events, showTasks, showHabits, showProjects, showMilestones, selectedDomains]); + + function toggleDomain(domain: string) { + setSelectedDomains((prev) => + prev.includes(domain) ? prev.filter((d) => d !== domain) : [...prev, domain] + ); + } + + if (loading) { + return ( +
+

Loading calendar...

+
+ ); + } + + return ( +
+
+
+

Calendar

+

Your commitments, in time.

+
+
+ +
+ {/* Filters sidebar */} + + + + + + + {/* Entity types */} +
+

Show

+
+
+ setShowTasks(checked === true)} + /> +
+
+ ); +} diff --git a/apps/web/app/(dashboard)/dashboard/page.tsx b/apps/web/app/(dashboard)/dashboard/page.tsx new file mode 100644 index 0000000..29e803f --- /dev/null +++ b/apps/web/app/(dashboard)/dashboard/page.tsx @@ -0,0 +1,183 @@ +'use client'; + +import React, { Suspense } from 'react'; +import dynamic from 'next/dynamic'; +import { useDashboardStore } from '@/lib/stores/use-dashboard-store'; +import { WidgetErrorBoundary } from '@/components/widget-error-boundary'; + +// Lazy load react-grid-layout (client-only, ~45KB) +const ResponsiveGridLayout = dynamic( + () => import('@/components/dashboard/responsive-grid-layout'), + { + ssr: false, + loading: () => ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ ))} +
+ ), + } +); + +// Lazy load individual widgets — each is code-split into its own chunk +const TodayTasksWidget = dynamic( + () => + import('@/components/dashboard/widgets/today-tasks-widget').then((m) => m.TodayTasksWidget), + { + ssr: false, + loading: () => , + } +); + +const HabitChecklistWidget = dynamic( + () => + import('@/components/dashboard/widgets/habit-checklist-widget').then( + (m) => m.HabitChecklistWidget + ), + { + ssr: false, + loading: () => , + } +); + +const WeeklyStatsWidget = dynamic( + () => + import('@/components/dashboard/widgets/weekly-stats-widget').then((m) => m.WeeklyStatsWidget), + { + ssr: false, + loading: () => , + } +); + +const ProjectProgressWidget = dynamic( + () => + import('@/components/dashboard/widgets/project-progress-widget').then( + (m) => m.ProjectProgressWidget + ), + { + ssr: false, + loading: () => , + } +); + +const HabitStreaksWidget = dynamic( + () => + import('@/components/dashboard/widgets/habit-streaks-widget').then((m) => m.HabitStreaksWidget), + { + ssr: false, + loading: () => , + } +); + +const CalendarMiniWidget = dynamic( + () => + import('@/components/dashboard/widgets/calendar-mini-widget').then((m) => m.CalendarMiniWidget), + { + ssr: false, + loading: () => , + } +); + +const QuickAddWidget = dynamic( + () => + import('@/components/dashboard/widgets/quick-add-widget').then((m) => m.QuickAddWidget), + { + ssr: false, + loading: () => , + } +); + +const RecentActivityWidget = dynamic( + () => + import('@/components/dashboard/widgets/recent-activity-widget').then( + (m) => m.RecentActivityWidget + ), + { + ssr: false, + loading: () => , + } +); + +function WidgetSkeleton() { + return ( +
+
+
+
+
+
+
+
+ ); +} + +const widgetComponents: Record = { + 'today-tasks': TodayTasksWidget, + 'habit-checklist': HabitChecklistWidget, + 'weekly-stats': WeeklyStatsWidget, + 'project-progress': ProjectProgressWidget, + 'habit-streaks': HabitStreaksWidget, + 'calendar-mini': CalendarMiniWidget, + 'quick-add': QuickAddWidget, + 'recent-activity': RecentActivityWidget, +}; + +export default function DashboardPage() { + const { widgets, setWidgets } = useDashboardStore(); + + const layout = widgets.map((w) => ({ + i: w.id, + x: w.x, + y: w.y, + w: w.w, + h: w.h, + })); + + function handleLayoutChange(newLayout: { i: string; x: number; y: number; w: number; h: number }[]) { + const updated = widgets.map((w) => { + const layoutItem = newLayout.find((l) => l.i === w.id); + if (layoutItem) { + return { + ...w, + x: layoutItem.x, + y: layoutItem.y, + w: layoutItem.w, + h: layoutItem.h, + }; + } + return w; + }); + setWidgets(updated); + } + + return ( +
+
+

Dashboard

+

Your day, at a glance.

+
+ + + {widgets.map((widget) => { + const WidgetComponent = widgetComponents[widget.id]; + if (!WidgetComponent) return null; + + return ( +
+ +
+
+ }> + + +
+
+
+
+ ); + })} +
+
+ ); +} diff --git a/apps/web/app/(dashboard)/habits/page.tsx b/apps/web/app/(dashboard)/habits/page.tsx new file mode 100644 index 0000000..59051fc --- /dev/null +++ b/apps/web/app/(dashboard)/habits/page.tsx @@ -0,0 +1,158 @@ +'use client'; + +import { useEffect, useState, Suspense } from 'react'; +import { Flame, Plus } from 'lucide-react'; +import dynamic from 'next/dynamic'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { HabitCard } from '@/components/habits/habit-card'; +import { HabitCompletionDialog } from '@/components/habits/habit-completion-dialog'; +import type { Habit } from '@project-e/shared'; + +// Lazy load react-calendar-heatmap (~15KB) +const HabitHeatmap = dynamic( + () => import('@/components/habits/habit-heatmap').then((m) => m.HabitHeatmap), + { + ssr: false, + loading: () => ( +
+ ), + } +); + +/** Extended habit with server-computed fields */ +interface HabitWithMeta extends Habit { + logged_today: boolean; +} + +export default function HabitsPage() { + const [habits, setHabits] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedHabit, setSelectedHabit] = useState(null); + const [completionDialogOpen, setCompletionDialogOpen] = useState(false); + + useEffect(() => { + fetchHabits(); + }, []); + + async function fetchHabits() { + try { + const response = await fetch('/api/habits'); + if (response.ok) { + const data = await response.json(); + setHabits(data.items || []); + } + } catch (error) { + console.error('Failed to fetch habits:', error); + } finally { + setLoading(false); + } + } + + function handleComplete(habit: HabitWithMeta) { + if (habit.completion_mode === 'quick') { + logHabitCompletion(habit.id, {}); + } else { + setSelectedHabit(habit); + setCompletionDialogOpen(true); + } + } + + async function logHabitCompletion( + habitId: string, + data: { mood?: number; value?: number; notes?: string } + ) { + try { + await fetch(`/api/habits/${habitId}/logs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + fetchHabits(); + setCompletionDialogOpen(false); + } catch (error) { + console.error('Failed to log habit:', error); + } + } + + const completedCount = habits.filter((h) => h.logged_today).length; + const completionRate = + habits.length > 0 ? Math.round((completedCount / habits.length) * 100) : 0; + + if (loading) { + return

Loading habits...

; + } + + return ( +
+
+
+

Habits

+

+ Small actions, visible momentum. +

+
+ +
+ + {/* Summary banner */} + + +
+

Today's progress

+

+ {completedCount} / {habits.length} habits +

+
+
+

Completion rate

+

{completionRate}%

+
+
+
+ + {/* Habit cards grid */} +
+ {habits.map((habit) => ( + handleComplete(habit)} + /> + ))} +
+ + {/* Heatmap section */} + + + + + + + + } + > + + + + + + {/* Completion dialog */} + {selectedHabit && ( + logHabitCompletion(selectedHabit.id, data)} + /> + )} +
+ ); +} diff --git a/apps/web/app/(dashboard)/layout.tsx b/apps/web/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..350c2d1 --- /dev/null +++ b/apps/web/app/(dashboard)/layout.tsx @@ -0,0 +1,27 @@ +import { Sidebar } from '@/components/sidebar'; +import { TopBar } from '@/components/topbar'; +import { NetworkErrorBanner } from '@/components/network-error-banner'; +import { KeyboardShortcutsProvider } from '@/components/keyboard-shortcuts-provider'; +import { WebVitalsTracker } from '@/components/web-vitals-tracker'; + +export default function DashboardLayout({ children }: { children: React.ReactNode }) { + return ( + + + + Skip to main content + +
+ + +
+ +
+ {children} +
+
+
+
+ + ); +} diff --git a/apps/web/app/(dashboard)/notes/page.tsx b/apps/web/app/(dashboard)/notes/page.tsx new file mode 100644 index 0000000..801ea7f --- /dev/null +++ b/apps/web/app/(dashboard)/notes/page.tsx @@ -0,0 +1,351 @@ +'use client'; + +import { useEffect, useState, Suspense } from 'react'; +import { Plus, FileText, Link2, GitBranch } from 'lucide-react'; +import dynamic from 'next/dynamic'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Badge } from '@/components/ui/badge'; +import { DailyNoteButton } from '@/components/notes/daily-note-button'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; + +// Lazy load TipTap editor (~80KB TipTap + extensions) +const NoteEditor = dynamic( + () => import('@/components/notes/note-editor').then((m) => m.NoteEditor), + { + ssr: false, + loading: () => ( +
+
Loading editor...
+
+ ), + } +); + +// Lazy load react-force-graph-2d (~120KB + three.js) +const NoteGraph = dynamic( + () => import('@/components/notes/note-graph').then((m) => m.NoteGraph), + { + ssr: false, + loading: () => ( +
+
Loading graph...
+
+ ), + } +); + +interface Note { + id: string; + title: string; + content: string; + domain: string; + created: string; + updated: string; +} + +interface Backlink { + id: string; + title: string; +} + +export default function NotesPage() { + const [notes, setNotes] = useState([]); + const [selectedNote, setSelectedNote] = useState(null); + const [backlinks, setBacklinks] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchNotes(); + }, []); + + useEffect(() => { + if (selectedNote) { + fetchBacklinks(selectedNote.id); + } + }, [selectedNote]); + + async function fetchNotes() { + try { + const response = await fetch('/api/notes?sort=-updated'); + if (response.ok) { + const data = await response.json(); + const notesList = data.items || []; + setNotes(notesList); + if (notesList.length > 0 && !selectedNote) { + setSelectedNote(notesList[0]); + } + } + } catch (error) { + console.error('Failed to fetch notes:', error); + } finally { + setLoading(false); + } + } + + async function fetchBacklinks(noteId: string) { + try { + const response = await fetch(`/api/notes/${noteId}/backlinks`); + if (response.ok) { + const data = await response.json(); + setBacklinks(data.items || []); + } + } catch (error) { + console.error('Failed to fetch backlinks:', error); + } + } + + async function createNote() { + try { + const response = await fetch('/api/notes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: 'Untitled note', + content: '', + domain: 'personal', + }), + }); + if (response.ok) { + const newNote = await response.json(); + setNotes([newNote, ...notes]); + setSelectedNote(newNote); + } + } catch (error) { + console.error('Failed to create note:', error); + } + } + + function handleDailyNoteReady(raw: Record) { + const note = raw as unknown as Note; + // If the note already appears in the list, just select it + const exists = notes.find((n) => n.id === note.id); + if (exists) { + setSelectedNote(exists); + return; + } + // Otherwise prepend it and select + setNotes([note, ...notes]); + setSelectedNote(note); + } + + async function updateNote(noteId: string, updates: Partial) { + try { + await fetch(`/api/notes/${noteId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updates), + }); + fetchNotes(); + } catch (error) { + console.error('Failed to update note:', error); + } + } + + async function deleteNote(noteId: string) { + if (!confirm('Are you sure you want to delete this note?')) return; + + try { + await fetch(`/api/notes/${noteId}`, { method: 'DELETE' }); + const updatedNotes = notes.filter((n) => n.id !== noteId); + setNotes(updatedNotes); + if (selectedNote?.id === noteId) { + setSelectedNote(updatedNotes[0] || null); + } + } catch (error) { + console.error('Failed to delete note:', error); + } + } + + if (loading) { + return

Loading notes...

; + } + + return ( +
+
+
+

Notes

+

+ Connect ideas to the work they shape. +

+
+
+ + +
+
+ +
+ {/* Notes list */} + + +
+ {notes.length === 0 ? ( +

+ No notes yet +

+ ) : ( +
+ {notes.map((note) => ( + + ))} +
+ )} +
+
+
+ + {/* Note editor */} + + {selectedNote ? ( +
+
+ + + setSelectedNote({ + ...selectedNote, + title: e.target.value, + }) + } + onBlur={() => + updateNote(selectedNote.id, { + title: selectedNote.title, + }) + } + className="w-full text-xl font-semibold outline-none" + placeholder="Note title" + /> +
+
+ +
+ Loading editor... +
+
+ } + > + + setSelectedNote({ ...selectedNote, content }) + } + onBlur={() => + updateNote(selectedNote.id, { + content: selectedNote.content, + }) + } + /> + +
+
+ ) : ( +
+

+ Select a note or create a new one +

+
+ )} + + + {/* Backlinks and graph */} + + +
+ + + + + + +
+ + + {backlinks.length === 0 ? ( +

+ No backlinks +

+ ) : ( +
+ {backlinks.map((link) => ( + + ))} +
+ )} +
+ + + +
+ Loading graph... +
+
+ } + > + + + + + +
+
+ ); +} diff --git a/apps/web/app/(dashboard)/projects/[id]/page.tsx b/apps/web/app/(dashboard)/projects/[id]/page.tsx new file mode 100644 index 0000000..fdff9b4 --- /dev/null +++ b/apps/web/app/(dashboard)/projects/[id]/page.tsx @@ -0,0 +1,393 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useParams } from 'next/navigation'; +import { ArrowLeft, Calendar, CheckCircle2, Circle, Flag } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Progress } from '@/components/ui/progress'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import Link from 'next/link'; + +interface Project { + id: string; + name: string; + description?: string; + status: 'active' | 'paused' | 'archived'; + domain: string; + progress: number; + task_count: number; + completed_count: number; + due_date?: string; +} + +interface Task { + id: string; + title: string; + status: 'todo' | 'in_progress' | 'done'; + priority: 'low' | 'medium' | 'high' | 'urgent'; + due_date?: string; +} + +interface Milestone { + id: string; + name: string; + description?: string; + due_date?: string; + status: 'planned' | 'in_progress' | 'completed'; + completed_tasks: number; + total_tasks: number; +} + +export default function ProjectDetailPage() { + const params = useParams(); + const projectId = params.id as string; + + const [project, setProject] = useState(null); + const [tasks, setTasks] = useState([]); + const [milestones, setMilestones] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (projectId) { + fetchProject(); + fetchTasks(); + fetchMilestones(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projectId]); + + async function fetchProject() { + try { + const response = await fetch(`/api/projects/${projectId}`); + if (response.ok) { + const data = await response.json(); + setProject(data); + } + } catch (error) { + console.error('Failed to fetch project:', error); + } + } + + async function fetchTasks() { + try { + const response = await fetch( + `/api/tasks?filter=project_id%3D%22${projectId}%22&sort=-created` + ); + if (response.ok) { + const data = await response.json(); + setTasks(data.items || []); + } + } catch (error) { + console.error('Failed to fetch tasks:', error); + } finally { + setLoading(false); + } + } + + async function fetchMilestones() { + try { + const response = await fetch( + `/api/milestones?filter=project_id%3D%22${projectId}%22&sort=due_date` + ); + if (response.ok) { + const data = await response.json(); + setMilestones(data.items || []); + } + } catch (error) { + console.error('Failed to fetch milestones:', error); + } + } + + async function toggleTaskComplete(taskId: string, currentStatus: string) { + const newStatus = currentStatus === 'done' ? 'todo' : 'done'; + try { + await fetch(`/api/tasks/${taskId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: newStatus }), + }); + fetchTasks(); + fetchProject(); + } catch (error) { + console.error('Failed to toggle task:', error); + } + } + + if (loading || !project) { + return

Loading project...

; + } + + return ( +
+ {/* Back button */} + + + + + {/* Project header */} +
+
+
+

{project.name}

+ {project.description && ( +

{project.description}

+ )} +
+ + {project.status} + +
+ + {/* Project stats */} +
+ + +
+
+

Progress

+

{project.progress}%

+
+
+ +
+
+ + + +
+
+

Tasks

+

+ {project.completed_count} / {project.task_count} +

+
+
+
+
+ + + +
+
+

Due Date

+

+ {project.due_date + ? new Date(project.due_date).toLocaleDateString() + : 'No date'} +

+
+
+
+
+
+
+ + {/* Tabs */} + + + Tasks ({tasks.length}) + + Milestones ({milestones.length}) + + Habits + Notes + + + + + + Project Tasks + + + {tasks.length === 0 ? ( +

+ No tasks yet +

+ ) : ( +
+ {tasks.map((task) => ( +
+ +
+

+ {task.title} +

+
+ + {task.priority} + + {task.due_date && ( + + {new Date(task.due_date).toLocaleDateString()} + + )} +
+ ))} +
+ )} +
+
+
+ + + + + Milestones + + + {milestones.length === 0 ? ( +

+ No milestones yet +

+ ) : ( +
+ {milestones.map((milestone, index) => ( +
+ {/* Timeline line */} + {index < milestones.length - 1 && ( +
+ )} + + {/* Milestone marker */} +
+
+ + {/* Milestone content */} +
+
+
+

+ {milestone.name} +

+ {milestone.description && ( +

+ {milestone.description} +

+ )} +
+ + {milestone.status} + +
+ {milestone.due_date && ( +

+ Due:{' '} + {new Date( + milestone.due_date + ).toLocaleDateString()} +

+ )} +
+
+ + Tasks + + + {milestone.completed_tasks} /{' '} + {milestone.total_tasks} + +
+ 0 + ? (milestone.completed_tasks / + milestone.total_tasks) * + 100 + : 0 + } + className="h-1.5" + /> +
+
+
+ ))} +
+ )} + + + + + + + + Habits linked to this project will appear here + + + + + + + + Notes linked to this project will appear here + + + + +
+ ); +} diff --git a/apps/web/app/(dashboard)/projects/page.tsx b/apps/web/app/(dashboard)/projects/page.tsx new file mode 100644 index 0000000..10a3740 --- /dev/null +++ b/apps/web/app/(dashboard)/projects/page.tsx @@ -0,0 +1,176 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Plus, FolderKanban } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Progress } from '@/components/ui/progress'; +import Link from 'next/link'; + +interface Project { + id: string; + name: string; + description?: string; + status: 'active' | 'paused' | 'archived'; + domain: string; + progress: number; + task_count: number; + completed_count: number; + due_date?: string; +} + +export default function ProjectsPage() { + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchProjects(); + }, []); + + async function fetchProjects() { + try { + const response = await fetch('/api/projects?sort=-created'); + if (response.ok) { + const data = await response.json(); + setProjects(data.items || []); + } + } catch (error) { + console.error('Failed to fetch projects:', error); + } finally { + setLoading(false); + } + } + + if (loading) { + return

Loading projects...

; + } + + const activeProjects = projects.filter((p) => p.status === 'active'); + const pausedProjects = projects.filter((p) => p.status === 'paused'); + const archivedProjects = projects.filter((p) => p.status === 'archived'); + + return ( +
+
+
+

Projects

+

Every outcome has a home.

+
+ +
+ + {/* Active projects */} + {activeProjects.length > 0 && ( +
+

Active Projects

+
+ {activeProjects.map((project) => ( + + ))} +
+
+ )} + + {/* Paused projects */} + {pausedProjects.length > 0 && ( +
+

Paused Projects

+
+ {pausedProjects.map((project) => ( + + ))} +
+
+ )} + + {/* Archived projects */} + {archivedProjects.length > 0 && ( +
+

Archived Projects

+
+ {archivedProjects.map((project) => ( + + ))} +
+
+ )} + + {projects.length === 0 && ( + + + + + )} +
+ ); +} + +function ProjectCard({ project }: { project: Project }) { + return ( + + + +
+
+ {project.name} + {project.description && ( +

+ {project.description} +

+ )} +
+ + {project.status} + +
+
+ + {/* Progress */} +
+
+ Progress + {project.progress}% +
+ +
+ + {/* Task count */} +
+ Tasks + + {project.completed_count} / {project.task_count} + +
+ + {/* Domain and due date */} +
+ {project.domain} + {project.due_date && ( + + Due: {new Date(project.due_date).toLocaleDateString()} + + )} +
+
+
+ + ); +} diff --git a/apps/web/app/(dashboard)/reports/page.tsx b/apps/web/app/(dashboard)/reports/page.tsx new file mode 100644 index 0000000..642fe31 --- /dev/null +++ b/apps/web/app/(dashboard)/reports/page.tsx @@ -0,0 +1,302 @@ +'use client'; + +import { useEffect, useState, Suspense } from 'react'; +import { Plus, FileBarChart, Calendar, Target, TrendingUp, Clock } from 'lucide-react'; +import dynamic from 'next/dynamic'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; + +// Lazy load TipTap report editor (~80KB) +const ReportEditor = dynamic( + () => import('@/components/reports/report-editor').then((m) => m.ReportEditor), + { + ssr: false, + loading: () => ( +
+
Loading editor...
+
+ ), + } +); + +// Lazy load report templates +const ReportTemplates = dynamic( + () => import('@/components/reports/report-templates').then((m) => m.ReportTemplates), + { + ssr: false, + loading: () => ( +
+
Loading templates...
+
+ ), + } +); + +interface Report { + id: string; + title: string; + content: string; + report_type: 'weekly' | 'monthly' | 'project' | 'habit' | 'custom'; + date_range_start?: string; + date_range_end?: string; + domain: string; + created: string; + updated: string; +} + +export default function ReportsPage() { + const [reports, setReports] = useState([]); + const [selectedReport, setSelectedReport] = useState(null); + const [loading, setLoading] = useState(true); + const [showTemplates, setShowTemplates] = useState(false); + + useEffect(() => { + fetchReports(); + }, []); + + async function fetchReports() { + try { + const response = await fetch('/api/reports?sort=-created'); + if (response.ok) { + const data = await response.json(); + const reportsList = data.items || []; + setReports(reportsList); + if (reportsList.length > 0 && !selectedReport) { + setSelectedReport(reportsList[0]); + } + } + } catch (error) { + console.error('Failed to fetch reports:', error); + } finally { + setLoading(false); + } + } + + async function createReport(overrides?: Partial) { + try { + const response = await fetch('/api/reports', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: 'Untitled report', + content: '', + report_type: 'custom', + domain: 'personal', + ...overrides, + }), + }); + if (response.ok) { + const newReport = await response.json(); + setReports([newReport, ...reports]); + setSelectedReport(newReport); + setShowTemplates(false); + } + } catch (error) { + console.error('Failed to create report:', error); + } + } + + async function updateReport(reportId: string, updates: Partial) { + try { + await fetch(`/api/reports/${reportId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updates), + }); + fetchReports(); + } catch (error) { + console.error('Failed to update report:', error); + } + } + + async function deleteReport(reportId: string) { + if (!confirm('Are you sure you want to delete this report?')) return; + + try { + await fetch(`/api/reports/${reportId}`, { method: 'DELETE' }); + const updatedReports = reports.filter((r) => r.id !== reportId); + setReports(updatedReports); + if (selectedReport?.id === reportId) { + setSelectedReport(updatedReports[0] || null); + } + } catch (error) { + console.error('Failed to delete report:', error); + } + } + + function getReportTypeIcon(type: string) { + switch (type) { + case 'weekly': + return ; + case 'monthly': + return ; + case 'project': + return ; + case 'habit': + return ; + case 'custom': + return ; + default: + return ; + } + } + + if (loading) { + return

Loading reports...

; + } + + if (showTemplates) { + return ( + +
Loading templates...
+
+ } + > + { + createReport({ + title: template.name, + report_type: template.type, + content: template.content, + }); + }} + onCancel={() => setShowTemplates(false)} + /> + + ); + } + + return ( +
+
+
+

Reports

+

Step back and see what changed.

+
+
+ + +
+
+ +
+ {/* Reports list */} + +
+ {reports.length === 0 ? ( +

+ No reports yet +

+ ) : ( +
+ {reports.map((report) => ( + + ))} +
+ )} +
+
+ + {/* Report editor */} + + {selectedReport ? ( +
+
+ + + setSelectedReport({ ...selectedReport, title: e.target.value }) + } + onBlur={() => + updateReport(selectedReport.id, { title: selectedReport.title }) + } + className="w-full text-xl font-semibold outline-none" + placeholder="Report title" + /> +
+ {selectedReport.report_type} + {selectedReport.domain} + {selectedReport.date_range_start && selectedReport.date_range_end && ( + + + {new Date(selectedReport.date_range_start).toLocaleDateString()} -{' '} + {new Date(selectedReport.date_range_end).toLocaleDateString()} + + )} +
+
+
+ +
+ Loading editor... +
+
+ } + > + + setSelectedReport({ ...selectedReport, content }) + } + onBlur={() => + updateReport(selectedReport.id, { content: selectedReport.content }) + } + /> + +
+
+ ) : ( +
+

Select a report or create a new one

+
+ )} + +
+
+ ); +} diff --git a/apps/web/app/(dashboard)/settings/error-log/page.tsx b/apps/web/app/(dashboard)/settings/error-log/page.tsx new file mode 100644 index 0000000..b565eac --- /dev/null +++ b/apps/web/app/(dashboard)/settings/error-log/page.tsx @@ -0,0 +1,128 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { AlertTriangle, Trash2 } from 'lucide-react'; + +interface ErrorLog { + id: string; + level: string; + source: string; + message: string; + metadata: Record; + created: string; +} + +export default function ErrorLogPage() { + const [errors, setErrors] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchErrors(); + }, []); + + async function fetchErrors() { + try { + const response = await fetch('/api/error-logs?limit=50'); + if (response.ok) { + const data = await response.json(); + setErrors(data.items || []); + } + } catch (error) { + console.error('Failed to fetch error logs:', error); + } finally { + setLoading(false); + } + } + + async function clearErrors() { + try { + const response = await fetch('/api/error-logs', { method: 'DELETE' }); + if (response.ok) { + setErrors([]); + } + } catch (error) { + console.error('Failed to clear error logs:', error); + } + } + + if (loading) { + return ( + + + Error Log + Loading... + + + ); + } + + return ( + + +
+
+ Error Log + + Recent errors from the application (auto-purged after 30 days) + +
+ {errors.length > 0 && ( + + )} +
+
+ + {errors.length === 0 ? ( +

+ No errors logged +

+ ) : ( +
+ {errors.map((error) => ( +
+
+
+
+ + {new Date(error.created).toLocaleString()} + +
+

{error.message}

+ {error.metadata && + Object.keys(error.metadata).length > 0 && ( +
+ + Details + +
+                        {JSON.stringify(error.metadata, null, 2)}
+                      
+
+ )} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/apps/web/app/(dashboard)/settings/page.tsx b/apps/web/app/(dashboard)/settings/page.tsx new file mode 100644 index 0000000..ef1d30c --- /dev/null +++ b/apps/web/app/(dashboard)/settings/page.tsx @@ -0,0 +1,101 @@ +'use client'; + +import { + Palette, + Globe, + Keyboard, + Bot, + Webhook, + Download, + AlertTriangle, +} from 'lucide-react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { SettingsAppearance } from '@/components/settings/settings-appearance'; +import { SettingsDomains } from '@/components/settings/settings-domains'; +import { SettingsShortcuts } from '@/components/settings/settings-shortcuts'; +import { SettingsAgents } from '@/components/settings/settings-agents'; +import { SettingsWebhooks } from '@/components/settings/settings-webhooks'; +import { SettingsImportExport } from '@/components/settings/settings-import-export'; + +export default function SettingsPage() { + return ( +
+
+

Settings

+

Tune Project E to fit your work.

+
+ + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + Error Log + View recent application errors + + +

+ See the{' '} + + detailed error log + {' '} + for more information. +

+
+
+
+
+
+
+ ); +} diff --git a/apps/web/app/(dashboard)/tasks/page.tsx b/apps/web/app/(dashboard)/tasks/page.tsx new file mode 100644 index 0000000..47df604 --- /dev/null +++ b/apps/web/app/(dashboard)/tasks/page.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { useState } from 'react'; +import { LayoutGrid, List } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { TasksKanbanView } from '@/components/tasks/tasks-kanban-view'; +import { TasksListView } from '@/components/tasks/tasks-list-view'; + +export default function TasksPage() { + const [view, setView] = useState<'kanban' | 'list'>('kanban'); + + return ( +
+
+
+

Tasks

+

+ Move work forward without losing the thread. +

+
+
+ setView(v as 'kanban' | 'list')} + > + + + + + + + +
+
+ + {view === 'kanban' ? : } +
+ ); +} diff --git a/apps/web/app/api/agent-activity/[id]/undo/route.ts b/apps/web/app/api/agent-activity/[id]/undo/route.ts new file mode 100644 index 0000000..48d2f0e --- /dev/null +++ b/apps/web/app/api/agent-activity/[id]/undo/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAuthUser, createErrorResponse } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; + +type RouteContext = { params: Promise<{ id: string }> }; + +// POST /api/agent-activity/[id]/undo — Undo an agent action +export async function POST(request: NextRequest, context: RouteContext) { + const user = await getAuthUser(request); + if (!user) { + return NextResponse.json( + { error: { code: 'UNAUTHORIZED', message: 'Authentication required' } }, + { status: 401 } + ); + } + + const { id } = await context.params; + + try { + const pb = createPocketBaseClient(); + + // Get the activity record + const activity = await pb.collection('agent_activity').getOne(id); + + if (!activity.before_state) { + return createErrorResponse('CANNOT_UNDO', 'This action cannot be undone', 400); + } + + // Restore the previous state + const entityType = activity.entity_type; + const entityId = activity.entity_id; + const beforeState = activity.before_state; + + await pb.collection(entityType).update(entityId, beforeState); + + return NextResponse.json({ success: true, message: 'Action undone' }); + } catch (error) { + console.error('Failed to undo activity:', error); + return createErrorResponse('UNDO_FAILED', 'Failed to undo action', 500); + } +} diff --git a/apps/web/app/api/agent-activity/route.ts b/apps/web/app/api/agent-activity/route.ts new file mode 100644 index 0000000..def4be1 --- /dev/null +++ b/apps/web/app/api/agent-activity/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; + +// GET /api/agent-activity — List agent activity +export const GET = withAuth(async (request: NextRequest) => { + const { searchParams } = new URL(request.url); + const page = parseInt(searchParams.get('page') || '1'); + const perPage = parseInt(searchParams.get('perPage') || '50'); + const sort = searchParams.get('sort') || '-created'; + + const pb = createPocketBaseClient(); + const result = await pb.collection('agent_activity').getList(page, perPage, { + sort, + }); + + return NextResponse.json({ + items: result.items, + totalItems: result.totalItems, + totalPages: result.totalPages, + page: result.page, + perPage: result.perPage, + }); +}); diff --git a/apps/web/app/api/agent-tasks/route.ts b/apps/web/app/api/agent-tasks/route.ts new file mode 100644 index 0000000..de6471d --- /dev/null +++ b/apps/web/app/api/agent-tasks/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; + +// GET /api/agent-tasks — List agent tasks +export const GET = withAuth(async (request: NextRequest) => { + const { searchParams } = new URL(request.url); + const page = parseInt(searchParams.get('page') || '1'); + const perPage = parseInt(searchParams.get('perPage') || '50'); + const sort = searchParams.get('sort') || '-created'; + + const pb = createPocketBaseClient(); + const result = await pb.collection('agent_tasks').getList(page, perPage, { + sort, + }); + + return NextResponse.json({ + items: result.items, + totalItems: result.totalItems, + totalPages: result.totalPages, + page: result.page, + perPage: result.perPage, + }); +}); diff --git a/apps/web/app/api/agent-webhook/route.ts b/apps/web/app/api/agent-webhook/route.ts new file mode 100644 index 0000000..3c572e3 --- /dev/null +++ b/apps/web/app/api/agent-webhook/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createAdminClient } from '@/lib/pocketbase'; +import { emitEvent, EVENTS } from '@/lib/events/event-bus'; + +// POST /api/agent-webhook — Receive async agent results +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { agent_task_id, result, status } = body; + + if (!agent_task_id) { + return NextResponse.json( + { error: { code: 'VALIDATION_ERROR', message: 'agent_task_id is required' } }, + { status: 400 }, + ); + } + + const pb = createAdminClient(); + + // Update agent task with result + await pb.collection('agent_tasks').update(agent_task_id, { + status: status || 'completed', + output: result || {}, + }); + + // Get the agent task to emit event + const agentTask = await pb.collection('agent_tasks').getOne(agent_task_id); + + // Emit completion event + emitEvent(EVENTS.AGENT_TASK_COMPLETED, { + agentTaskId: agent_task_id, + agentId: agentTask.agent_id as string, + entityType: (agentTask.entity_type as string) || '', + entityId: (agentTask.entity_id as string) || '', + userId: 'agent', + }); + + return NextResponse.json({ success: true }); + } catch { + return NextResponse.json( + { error: { code: 'INTERNAL_ERROR', message: 'Failed to process agent webhook' } }, + { status: 500 }, + ); + } +} diff --git a/apps/web/app/api/agents/[id]/route.ts b/apps/web/app/api/agents/[id]/route.ts new file mode 100644 index 0000000..5b218df --- /dev/null +++ b/apps/web/app/api/agents/[id]/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth, createErrorResponse } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; +import { updateAgentSchema } from '@project-e/shared'; +import { z } from 'zod'; + +type RouteContext = { params: Promise<{ id: string }> }; + +// GET /api/agents/[id] — Get a single agent +export const GET = withAuth(async (request: NextRequest, _user, context) => { + const { id } = await context!.params; + + const pb = createPocketBaseClient(); + const agent = await pb.collection('agents').getOne(id); + + return NextResponse.json(agent); +}); + +// PATCH /api/agents/[id] — Update an agent +export const PATCH = withAuth(async (request: NextRequest, _user, context) => { + try { + const { id } = await context!.params; + const body = await request.json(); + const data = updateAgentSchema.parse(body); + + const pb = createPocketBaseClient(); + const agent = await pb.collection('agents').update(id, data); + + return NextResponse.json(agent); + } catch (error) { + if (error instanceof z.ZodError) { + return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues); + } + throw error; + } +}); + +// DELETE /api/agents/[id] — Delete an agent +export const DELETE = withAuth(async (request: NextRequest, _user, context) => { + const { id } = await context!.params; + + const pb = createPocketBaseClient(); + await pb.collection('agents').delete(id); + + return new NextResponse(null, { status: 204 }); +}); diff --git a/apps/web/app/api/agents/route.ts b/apps/web/app/api/agents/route.ts new file mode 100644 index 0000000..aace67b --- /dev/null +++ b/apps/web/app/api/agents/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth, createErrorResponse } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; +import { createAgentSchema } from '@project-e/shared'; +import { z } from 'zod'; + +// GET /api/agents — List agents with filtering, sorting, pagination +export const GET = withAuth(async (request: NextRequest, _user) => { + const { searchParams } = new URL(request.url); + const page = parseInt(searchParams.get('page') || '1'); + const perPage = parseInt(searchParams.get('perPage') || '50'); + const filter = searchParams.get('filter') || ''; + const sort = searchParams.get('sort') || '-created'; + + const pb = createPocketBaseClient(); + const result = await pb.collection('agents').getList(page, perPage, { + filter, + sort, + }); + + return NextResponse.json({ + items: result.items, + totalItems: result.totalItems, + totalPages: result.totalPages, + page: result.page, + perPage: result.perPage, + }); +}); + +// POST /api/agents — Create an agent with auto-generated API key +export const POST = withAuth(async (request: NextRequest, _user) => { + try { + const body = await request.json(); + const data = createAgentSchema.parse(body); + + const pb = createPocketBaseClient(); + const agent = await pb.collection('agents').create({ + ...data, + api_key: crypto.randomUUID(), + }); + + return NextResponse.json(agent, { status: 201 }); + } catch (error) { + if (error instanceof z.ZodError) { + return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues); + } + throw error; + } +}); diff --git a/apps/web/app/api/analytics/route.ts b/apps/web/app/api/analytics/route.ts new file mode 100644 index 0000000..200295c --- /dev/null +++ b/apps/web/app/api/analytics/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; + +// GET /api/analytics — Pre-computed analytics data +export const GET = withAuth(async (request: NextRequest, _user) => { + const { searchParams } = new URL(request.url); + const period = searchParams.get('period') || '30'; // days + const days = parseInt(period); + + const pb = createPocketBaseClient(); + const startDate = new Date(); + startDate.setDate(startDate.getDate() - days); + const startStr = startDate.toISOString(); + + // Task completion rate + const tasks = await pb.collection('tasks').getFullList({ + filter: `created >= "${startStr}"`, + }); + const completedTasks = tasks.filter((t: Record) => t.status === 'done'); + const taskCompletionRate = tasks.length > 0 ? Math.round((completedTasks.length / tasks.length) * 100) : 0; + + // Habit consistency + const habits = await pb.collection('habits').getFullList(); + const habitLogs = await pb.collection('habit_logs').getFullList({ + filter: `logged_at >= "${startStr}"`, + }); + const habitConsistency = habits.length > 0 + ? Math.round((habitLogs.length / (habits.length * days)) * 100) + : 0; + + // Time tracked + const timeEntries = await pb.collection('task_time_entries').getFullList({ + filter: `started_at >= "${startStr}"`, + }); + const totalTimeMinutes = timeEntries.reduce( + (sum: number, e: Record) => sum + ((e.duration_minutes as number) || 0), + 0, + ); + + // Active streaks + const activeStreaks = habits.filter( + (h: Record) => ((h.current_streak as number) || 0) > 0, + ); + const bestStreak = Math.max( + ...habits.map((h: Record) => (h.best_streak as number) || 0), + 0, + ); + + return NextResponse.json({ + taskCompletionRate, + habitConsistency, + totalTimeMinutes, + activeStreaks: activeStreaks.length, + bestStreak, + period: days, + }, { + headers: { + 'Cache-Control': 'private, max-age=300, stale-while-revalidate=600', + }, + }); +}); diff --git a/apps/web/app/api/analytics/vitals/route.ts b/apps/web/app/api/analytics/vitals/route.ts new file mode 100644 index 0000000..e07178c --- /dev/null +++ b/apps/web/app/api/analytics/vitals/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from 'next/server'; + +// POST /api/analytics/vitals — Receive Web Vitals metrics +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + + // Log to console in development for debugging + if (process.env.NODE_ENV === 'development') { + console.log('[Web Vitals]', body); + } + + // In production, this would send to your analytics service + // (e.g., Google Analytics, PostHog, or custom backend) + // For now, just acknowledge receipt + + return NextResponse.json({ received: true }); + } catch { + // Silently ignore malformed requests + return NextResponse.json({ received: false }, { status: 400 }); + } +} diff --git a/apps/web/app/api/attachments/upload/route.ts b/apps/web/app/api/attachments/upload/route.ts new file mode 100644 index 0000000..c4ad56a --- /dev/null +++ b/apps/web/app/api/attachments/upload/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth, createErrorResponse } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; + +// POST /api/attachments/upload — Upload file attachment +export const POST = withAuth(async (request: NextRequest, _user) => { + try { + const formData = await request.formData(); + const file = formData.get('file') as File | null; + const taskId = formData.get('task_id') as string | null; + + if (!file) { + return createErrorResponse('VALIDATION_ERROR', 'File is required', 400); + } + + if (!taskId) { + return createErrorResponse('VALIDATION_ERROR', 'task_id is required', 400); + } + + // Check file size (5MB limit) + if (file.size > 5 * 1024 * 1024) { + return createErrorResponse('FILE_TOO_LARGE', 'File size must be less than 5MB', 400); + } + + const pb = createPocketBaseClient(); + + // Upload to PocketBase + const attachment = await pb.collection('task_attachments').create({ + task_id: taskId, + file, + filename: file.name, + mime_type: file.type, + size: file.size, + }); + + return NextResponse.json(attachment, { status: 201 }); + } catch { + return createErrorResponse('UPLOAD_FAILED', 'Failed to upload file', 500); + } +}); diff --git a/apps/web/app/api/auth/login/route.ts b/apps/web/app/api/auth/login/route.ts new file mode 100644 index 0000000..70d0c52 --- /dev/null +++ b/apps/web/app/api/auth/login/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createPocketBaseClient } from '@/lib/pocketbase'; +import { z } from 'zod'; + +const loginSchema = z.object({ + email: z.string().email(), + password: z.string().min(1), +}); + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { email, password } = loginSchema.parse(body); + + const pb = createPocketBaseClient(); + + // Authenticate with PocketBase + const authData = await pb.collection('users').authWithPassword(email, password); + + // Set auth token in httpOnly cookie + const response = NextResponse.json({ + user: { + id: authData.record.id, + email: authData.record.email, + name: authData.record.name || authData.record.email, + }, + token: authData.token, + }); + + response.cookies.set('pb_auth', authData.token, { + httpOnly: true, + secure: process.env.COOKIE_SECURE === 'true', + sameSite: 'lax', + path: '/', + maxAge: 60 * 60 * 24 * 7, // 7 days + }); + + return response; + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: { code: 'VALIDATION_ERROR', message: 'Invalid input', details: error.issues } }, + { status: 400 } + ); + } + + return NextResponse.json( + { error: { code: 'AUTH_ERROR', message: 'Invalid email or password' } }, + { status: 401 } + ); + } +} diff --git a/apps/web/app/api/auth/logout/route.ts b/apps/web/app/api/auth/logout/route.ts new file mode 100644 index 0000000..be3fe73 --- /dev/null +++ b/apps/web/app/api/auth/logout/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from 'next/server'; + +export async function POST() { + const response = NextResponse.json({ success: true }); + + // Clear auth cookie + response.cookies.set('pb_auth', '', { + httpOnly: true, + secure: process.env.COOKIE_SECURE === 'true', + sameSite: 'lax', + path: '/', + maxAge: 0, // Expire immediately + }); + + return response; +} diff --git a/apps/web/app/api/auth/me/route.ts b/apps/web/app/api/auth/me/route.ts new file mode 100644 index 0000000..96cb788 --- /dev/null +++ b/apps/web/app/api/auth/me/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createPocketBaseClient } from '@/lib/pocketbase'; + +export async function GET(request: NextRequest) { + try { + const token = request.cookies.get('pb_auth')?.value; + + if (!token) { + return NextResponse.json( + { error: { code: 'UNAUTHORIZED', message: 'Not authenticated' } }, + { status: 401 } + ); + } + + const pb = createPocketBaseClient(token); + + // Get current user + const authData = await pb.collection('users').authRefresh(); + + return NextResponse.json({ + user: { + id: authData.record.id, + email: authData.record.email, + name: authData.record.name || authData.record.email, + }, + }); + } catch { + return NextResponse.json( + { error: { code: 'AUTH_ERROR', message: 'Invalid or expired token' } }, + { status: 401 } + ); + } +} diff --git a/apps/web/app/api/auth/refresh/route.ts b/apps/web/app/api/auth/refresh/route.ts new file mode 100644 index 0000000..c7d57da --- /dev/null +++ b/apps/web/app/api/auth/refresh/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createPocketBaseClient } from '@/lib/pocketbase'; + +export async function POST(request: NextRequest) { + try { + const token = request.cookies.get('pb_auth')?.value; + + if (!token) { + return NextResponse.json( + { error: { code: 'UNAUTHORIZED', message: 'No auth token' } }, + { status: 401 } + ); + } + + const pb = createPocketBaseClient(token); + + // Refresh the auth token + await pb.collection('users').authRefresh(); + + const newToken = pb.authStore.token; + + const response = NextResponse.json({ + token: newToken, + }); + + response.cookies.set('pb_auth', newToken, { + httpOnly: true, + secure: process.env.COOKIE_SECURE === 'true', + sameSite: 'lax', + path: '/', + maxAge: 60 * 60 * 24 * 7, // 7 days + }); + + return response; + } catch { + return NextResponse.json( + { error: { code: 'AUTH_ERROR', message: 'Token refresh failed' } }, + { status: 401 } + ); + } +} diff --git a/apps/web/app/api/canvases/[id]/route.ts b/apps/web/app/api/canvases/[id]/route.ts new file mode 100644 index 0000000..ba1d6b2 --- /dev/null +++ b/apps/web/app/api/canvases/[id]/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth, createErrorResponse } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; +import { updateCanvasSchema } from '@project-e/shared'; +import { z } from 'zod'; + +type RouteContext = { params: Promise<{ id: string }> }; + +// GET /api/canvases/[id] — Get a single canvas +export const GET = withAuth(async (request: NextRequest, _user, context) => { + const { id } = await context!.params; + + const pb = createPocketBaseClient(); + const canvas = await pb.collection('canvases').getOne(id); + + return NextResponse.json(canvas); +}); + +// PATCH /api/canvases/[id] — Update a canvas +export const PATCH = withAuth(async (request: NextRequest, _user, context) => { + try { + const { id } = await context!.params; + const body = await request.json(); + const data = updateCanvasSchema.parse(body); + + const pb = createPocketBaseClient(); + const canvas = await pb.collection('canvases').update(id, data); + + return NextResponse.json(canvas); + } catch (error) { + if (error instanceof z.ZodError) { + return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues); + } + throw error; + } +}); + +// DELETE /api/canvases/[id] — Delete a canvas +export const DELETE = withAuth(async (request: NextRequest, _user, context) => { + const { id } = await context!.params; + + const pb = createPocketBaseClient(); + await pb.collection('canvases').delete(id); + + return new NextResponse(null, { status: 204 }); +}); diff --git a/apps/web/app/api/canvases/route.ts b/apps/web/app/api/canvases/route.ts new file mode 100644 index 0000000..a1a57e6 --- /dev/null +++ b/apps/web/app/api/canvases/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth, createErrorResponse } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; +import { createCanvasSchema } from '@project-e/shared'; +import { z } from 'zod'; + +// GET /api/canvases — List canvases with filtering, sorting, pagination +export const GET = withAuth(async (request: NextRequest, _user) => { + const { searchParams } = new URL(request.url); + const page = parseInt(searchParams.get('page') || '1'); + const perPage = parseInt(searchParams.get('perPage') || '50'); + const filter = searchParams.get('filter') || ''; + const sort = searchParams.get('sort') || '-created'; + + const pb = createPocketBaseClient(); + const result = await pb.collection('canvases').getList(page, perPage, { + filter, + sort, + }); + + return NextResponse.json({ + items: result.items, + totalItems: result.totalItems, + totalPages: result.totalPages, + page: result.page, + perPage: result.perPage, + }); +}); + +// POST /api/canvases — Create a canvas +export const POST = withAuth(async (request: NextRequest, _user) => { + try { + const body = await request.json(); + const data = createCanvasSchema.parse(body); + + const pb = createPocketBaseClient(); + const canvas = await pb.collection('canvases').create(data); + + return NextResponse.json(canvas, { status: 201 }); + } catch (error) { + if (error instanceof z.ZodError) { + return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues); + } + throw error; + } +}); diff --git a/apps/web/app/api/domains/[id]/route.ts b/apps/web/app/api/domains/[id]/route.ts new file mode 100644 index 0000000..76070f4 --- /dev/null +++ b/apps/web/app/api/domains/[id]/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth, createErrorResponse } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; +import { updateDomainSchema } from '@project-e/shared'; +import { z } from 'zod'; + +type RouteContext = { params: Promise<{ id: string }> }; + +// GET /api/domains/[id] — Get a single domain +export const GET = withAuth(async (request: NextRequest, _user, context) => { + const { id } = await context!.params; + + const pb = createPocketBaseClient(); + const domain = await pb.collection('domains').getOne(id); + + return NextResponse.json(domain); +}); + +// PATCH /api/domains/[id] — Update a domain +export const PATCH = withAuth(async (request: NextRequest, _user, context) => { + try { + const { id } = await context!.params; + const body = await request.json(); + const data = updateDomainSchema.parse(body); + + const pb = createPocketBaseClient(); + const domain = await pb.collection('domains').update(id, data); + + return NextResponse.json(domain); + } catch (error) { + if (error instanceof z.ZodError) { + return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues); + } + throw error; + } +}); + +// DELETE /api/domains/[id] — Delete a domain +export const DELETE = withAuth(async (request: NextRequest, _user, context) => { + const { id } = await context!.params; + + const pb = createPocketBaseClient(); + await pb.collection('domains').delete(id); + + return new NextResponse(null, { status: 204 }); +}); diff --git a/apps/web/app/api/domains/route.ts b/apps/web/app/api/domains/route.ts new file mode 100644 index 0000000..63b0d2e --- /dev/null +++ b/apps/web/app/api/domains/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth, createErrorResponse } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; +import { createDomainSchema } from '@project-e/shared'; +import { z } from 'zod'; + +// GET /api/domains — List domains with filtering, sorting, pagination +export const GET = withAuth(async (request: NextRequest, _user) => { + const { searchParams } = new URL(request.url); + const page = parseInt(searchParams.get('page') || '1'); + const perPage = parseInt(searchParams.get('perPage') || '50'); + const filter = searchParams.get('filter') || ''; + const sort = searchParams.get('sort') || 'sort_order'; + + const pb = createPocketBaseClient(); + const result = await pb.collection('domains').getList(page, perPage, { + filter, + sort, + }); + + return NextResponse.json({ + items: result.items, + totalItems: result.totalItems, + totalPages: result.totalPages, + page: result.page, + perPage: result.perPage, + }); +}); + +// POST /api/domains — Create a domain +export const POST = withAuth(async (request: NextRequest, _user) => { + try { + const body = await request.json(); + const data = createDomainSchema.parse(body); + + const pb = createPocketBaseClient(); + const domain = await pb.collection('domains').create(data); + + return NextResponse.json(domain, { status: 201 }); + } catch (error) { + if (error instanceof z.ZodError) { + return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues); + } + throw error; + } +}); diff --git a/apps/web/app/api/error-logs/route.ts b/apps/web/app/api/error-logs/route.ts new file mode 100644 index 0000000..ddd5a61 --- /dev/null +++ b/apps/web/app/api/error-logs/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; + +// GET /api/error-logs — List recent error logs +export const GET = withAuth(async (request: NextRequest) => { + const { searchParams } = new URL(request.url); + const limit = parseInt(searchParams.get('limit') || '50'); + + const pb = createPocketBaseClient(); + const result = await pb.collection('error_logs').getList(1, limit, { + sort: '-created', + }); + + return NextResponse.json({ + items: result.items, + totalItems: result.totalItems, + }); +}); + +// DELETE /api/error-logs — Clear all error logs +export const DELETE = withAuth(async () => { + const pb = createPocketBaseClient(); + + // Get all error logs and delete them + const logs = await pb.collection('error_logs').getFullList(); + + for (const log of logs) { + await pb.collection('error_logs').delete(log.id); + } + + return NextResponse.json({ deleted: logs.length }); +}); diff --git a/apps/web/app/api/export/route.ts b/apps/web/app/api/export/route.ts new file mode 100644 index 0000000..8b701ff --- /dev/null +++ b/apps/web/app/api/export/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth, createErrorResponse } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; + +const COLLECTIONS = [ + 'tasks', + 'habits', + 'projects', + 'notes', + 'reports', + 'milestones', + 'domains', + 'tags', + 'agents', + 'webhooks', +] as const; + +type ExportCollection = (typeof COLLECTIONS)[number]; + +// POST /api/export — Export all data as JSON +export const POST = withAuth(async (request: NextRequest, _user) => { + let body: { collections?: ExportCollection[] } = {}; + try { + body = await request.json(); + } catch { + // Empty body is fine — export everything + } + + const requestedCollections = body.collections && body.collections.length > 0 + ? body.collections.filter((c): c is ExportCollection => COLLECTIONS.includes(c as ExportCollection)) + : [...COLLECTIONS]; + + const pb = createPocketBaseClient(); + const exportData: Record = { + version: '1.0', + exportedAt: new Date().toISOString(), + }; + + for (const collection of requestedCollections) { + try { + const result = await pb.collection(collection).getList(1, 1000, { + sort: 'created', + }); + exportData[collection] = result.items; + } catch (error) { + console.error(`Failed to export collection ${collection}:`, error); + exportData[collection] = []; + } + } + + return NextResponse.json(exportData); +}); + +// GET /api/export — List available collections for export +export const GET = withAuth(async (_request: NextRequest, _user) => { + return NextResponse.json({ + collections: COLLECTIONS.map((name) => ({ + name, + label: name.charAt(0).toUpperCase() + name.slice(1).replace(/_/g, ' '), + })), + }); +}); diff --git a/apps/web/app/api/habit-logs/route.ts b/apps/web/app/api/habit-logs/route.ts new file mode 100644 index 0000000..82b3609 --- /dev/null +++ b/apps/web/app/api/habit-logs/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; + +// GET /api/habit-logs — List habit logs with date filtering +export const GET = withAuth(async (request: NextRequest) => { + const { searchParams } = new URL(request.url); + const start = searchParams.get('start'); + const end = searchParams.get('end'); + const habitId = searchParams.get('habit_id'); + + let filter = ''; + if (start && end) { + filter = `logged_at >= "${start}" && logged_at <= "${end}"`; + } else if (start) { + filter = `logged_at >= "${start}"`; + } else if (habitId) { + filter = `habit_id = "${habitId}"`; + } + + const pb = createPocketBaseClient(); + const result = await pb.collection('habit_logs').getList(1, 1000, { + filter, + sort: '-logged_at', + }); + + return NextResponse.json({ + items: result.items, + totalItems: result.totalItems, + }); +}); diff --git a/apps/web/app/api/habits/[id]/logs/route.ts b/apps/web/app/api/habits/[id]/logs/route.ts new file mode 100644 index 0000000..04c17be --- /dev/null +++ b/apps/web/app/api/habits/[id]/logs/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth, createErrorResponse } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; +import { logHabitCompletion } from '@/lib/services'; +import { z } from 'zod'; + +type RouteContext = { params: Promise<{ id: string }> }; + +// GET /api/habits/[id]/logs — List logs for a habit +export const GET = withAuth(async (request: NextRequest, _user, context) => { + const { id } = await context!.params; + const { searchParams } = new URL(request.url); + const page = parseInt(searchParams.get('page') || '1'); + const perPage = parseInt(searchParams.get('perPage') || '50'); + const filter = searchParams.get('filter') || ''; + const sort = searchParams.get('sort') || '-logged_at'; + + const pb = createPocketBaseClient(); + const result = await pb.collection('habit_logs').getList(page, perPage, { + filter: filter ? `habit_id = "${id}" && ${filter}` : `habit_id = "${id}"`, + sort, + }); + + return NextResponse.json({ + items: result.items, + totalItems: result.totalItems, + totalPages: result.totalPages, + page: result.page, + perPage: result.perPage, + }); +}); + +// POST /api/habits/[id]/logs — Create a habit log entry +export const POST = withAuth(async (request: NextRequest, _user, context) => { + try { + const { id } = await context!.params; + const body = await request.json(); + const data = z + .object({ + logged_at: z.string().datetime().optional(), + mood: z.number().int().min(1).max(5).optional(), + value: z.number().optional(), + notes: z.string().optional(), + }) + .parse(body); + + const result = await logHabitCompletion(id, data); + + return NextResponse.json(result, { status: 201 }); + } catch (error) { + if (error instanceof z.ZodError) { + return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues); + } + throw error; + } +}); diff --git a/apps/web/app/api/habits/[id]/route.ts b/apps/web/app/api/habits/[id]/route.ts new file mode 100644 index 0000000..f45ee00 --- /dev/null +++ b/apps/web/app/api/habits/[id]/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth, createErrorResponse } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; +import { updateHabitSchema } from '@project-e/shared'; +import { z } from 'zod'; + +type RouteContext = { params: Promise<{ id: string }> }; + +// GET /api/habits/[id] — Get a single habit +export const GET = withAuth(async (request: NextRequest, _user, context) => { + const { id } = await context!.params; + + const pb = createPocketBaseClient(); + const habit = await pb.collection('habits').getOne(id); + + return NextResponse.json(habit); +}); + +// PATCH /api/habits/[id] — Update a habit +export const PATCH = withAuth(async (request: NextRequest, _user, context) => { + try { + const { id } = await context!.params; + const body = await request.json(); + const data = updateHabitSchema.parse(body); + + const pb = createPocketBaseClient(); + const habit = await pb.collection('habits').update(id, data); + + return NextResponse.json(habit); + } catch (error) { + if (error instanceof z.ZodError) { + return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues); + } + throw error; + } +}); + +// DELETE /api/habits/[id] — Delete a habit +export const DELETE = withAuth(async (request: NextRequest, _user, context) => { + const { id } = await context!.params; + + const pb = createPocketBaseClient(); + await pb.collection('habits').delete(id); + + return new NextResponse(null, { status: 204 }); +}); diff --git a/apps/web/app/api/habits/route.ts b/apps/web/app/api/habits/route.ts new file mode 100644 index 0000000..ee84ce1 --- /dev/null +++ b/apps/web/app/api/habits/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth, createErrorResponse } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; +import { createHabitSchema } from '@project-e/shared'; +import { z } from 'zod'; + +// GET /api/habits — List habits with filtering, sorting, pagination +export const GET = withAuth(async (request: NextRequest, _user) => { + const { searchParams } = new URL(request.url); + const page = parseInt(searchParams.get('page') || '1'); + const perPage = parseInt(searchParams.get('perPage') || '50'); + const filter = searchParams.get('filter') || ''; + const sort = searchParams.get('sort') || '-created'; + + const pb = createPocketBaseClient(); + const result = await pb.collection('habits').getList(page, perPage, { + filter, + sort, + }); + + const response = NextResponse.json({ + items: result.items, + totalItems: result.totalItems, + totalPages: result.totalPages, + page: result.page, + perPage: result.perPage, + }); + + response.headers.set( + 'Cache-Control', + 'private, max-age=60, stale-while-revalidate=300' + ); + + return response; +}); + +// POST /api/habits — Create a habit +export const POST = withAuth(async (request: NextRequest, _user) => { + try { + const body = await request.json(); + const data = createHabitSchema.parse(body); + + const pb = createPocketBaseClient(); + const habit = await pb.collection('habits').create(data); + + return NextResponse.json(habit, { status: 201 }); + } catch (error) { + if (error instanceof z.ZodError) { + return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues); + } + throw error; + } +}); diff --git a/apps/web/app/api/habits/streaks/route.ts b/apps/web/app/api/habits/streaks/route.ts new file mode 100644 index 0000000..6f98d04 --- /dev/null +++ b/apps/web/app/api/habits/streaks/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server'; +import { withAuth } from '@/lib/auth'; +import { getHabitStreaks } from '@/lib/services/habit-service'; + +// GET /api/habits/streaks — Get all habit streaks +export const GET = withAuth(async () => { + const streaks = await getHabitStreaks(); + return NextResponse.json({ streaks }, { + headers: { + 'Cache-Control': 'private, max-age=60, stale-while-revalidate=300', + }, + }); +}); diff --git a/apps/web/app/api/health/route.ts b/apps/web/app/api/health/route.ts new file mode 100644 index 0000000..0ae8eee --- /dev/null +++ b/apps/web/app/api/health/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from 'next/server'; + +export async function GET() { + return NextResponse.json({ + status: 'ok', + timestamp: new Date().toISOString(), + version: process.env.npm_package_version || '0.1.0', + }); +} diff --git a/apps/web/app/api/import/route.ts b/apps/web/app/api/import/route.ts new file mode 100644 index 0000000..e5fd96e --- /dev/null +++ b/apps/web/app/api/import/route.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth, createErrorResponse } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; + +const COLLECTIONS = [ + 'tasks', + 'habits', + 'projects', + 'notes', + 'reports', + 'milestones', + 'domains', + 'tags', + 'agents', + 'webhooks', +] as const; + +type ImportCollection = (typeof COLLECTIONS)[number]; + +interface ImportResult { + collection: string; + imported: number; + failed: number; + errors: string[]; +} + +// POST /api/import — Import data from JSON +export const POST = withAuth(async (request: NextRequest, _user) => { + const body = await request.json(); + + if (!body || typeof body !== 'object') { + return createErrorResponse('INVALID_DATA', 'Invalid import data format', 400); + } + + if (!body.version) { + return createErrorResponse('INVALID_DATA', 'Missing version field — is this a valid Project E export?', 400); + } + + const pb = createPocketBaseClient(); + const results: ImportResult[] = []; + let totalImported = 0; + let totalFailed = 0; + + for (const collection of COLLECTIONS) { + const items = body[collection]; + if (!Array.isArray(items) || items.length === 0) continue; + + const result: ImportResult = { + collection, + imported: 0, + failed: 0, + errors: [], + }; + + for (const item of items) { + try { + // Strip id, created, updated to let PocketBase generate new ones + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { id, created, updated, ...data } = item; + await pb.collection(collection).create(data); + result.imported++; + } catch (error) { + result.failed++; + const message = error instanceof Error ? error.message : String(error); + if (result.errors.length < 5) { + result.errors.push(message); + } + } + } + + results.push(result); + totalImported += result.imported; + totalFailed += result.failed; + } + + return NextResponse.json({ + success: totalFailed === 0, + imported: totalImported, + failed: totalFailed, + results, + }); +}); diff --git a/apps/web/app/api/mcp/route.ts b/apps/web/app/api/mcp/route.ts new file mode 100644 index 0000000..92f459e --- /dev/null +++ b/apps/web/app/api/mcp/route.ts @@ -0,0 +1,127 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; +import { createMcpServer } from '@/lib/mcp/server'; +import { createAdminClient } from '@/lib/pocketbase'; + +// Store transports by session ID for stateful mode +const transports = new Map(); + +async function authenticateRequest(request: NextRequest): Promise { + // Check for API key in Authorization header + const authHeader = request.headers.get('Authorization'); + if (!authHeader) return false; + + const apiKey = authHeader.replace('Bearer ', '').trim(); + if (!apiKey) return false; + + try { + const pb = createAdminClient(); + // Look up agent by API key + const result = await pb.collection('agents').getList(1, 1, { + filter: `api_key = "${apiKey}" && status = "active"`, + }); + return result.items.length > 0; + } catch { + return false; + } +} + +export async function GET(request: NextRequest) { + // Authenticate + if (!(await authenticateRequest(request))) { + return NextResponse.json( + { error: 'Unauthorized. Provide a valid API key in the Authorization header.' }, + { status: 401 } + ); + } + + // Create server and transport for SSE connection + const server = createMcpServer(); + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID(), + }); + + await server.connect(transport); + + // Store transport for POST requests + if (transport.sessionId) { + transports.set(transport.sessionId, transport); + } + + // Handle the request + return transport.handleRequest(request); +} + +export async function POST(request: NextRequest) { + // Authenticate + if (!(await authenticateRequest(request))) { + return NextResponse.json( + { error: 'Unauthorized. Provide a valid API key in the Authorization header.' }, + { status: 401 } + ); + } + + // Get session ID from header + const sessionId = request.headers.get('mcp-session-id'); + + if (sessionId) { + // Route to existing transport + const transport = transports.get(sessionId); + if (transport) { + return transport.handleRequest(request); + } + return NextResponse.json( + { error: 'Session not found. Connect via GET first.' }, + { status: 404 } + ); + } + + // No session ID — this should be an initialization request + const server = createMcpServer(); + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID(), + }); + + await server.connect(transport); + + // Store transport for subsequent requests + if (transport.sessionId) { + transports.set(transport.sessionId, transport); + } + + return transport.handleRequest(request); +} + +export async function DELETE(request: NextRequest) { + // Authenticate + if (!(await authenticateRequest(request))) { + return NextResponse.json( + { error: 'Unauthorized. Provide a valid API key in the Authorization header.' }, + { status: 401 } + ); + } + + const sessionId = request.headers.get('mcp-session-id'); + if (!sessionId) { + return NextResponse.json( + { error: 'Missing mcp-session-id header' }, + { status: 400 } + ); + } + + const transport = transports.get(sessionId); + if (!transport) { + return NextResponse.json( + { error: 'Session not found' }, + { status: 404 } + ); + } + + // Handle the DELETE to terminate the session + const response = await transport.handleRequest(request); + + // Clean up + transports.delete(sessionId); + + return response; +} diff --git a/apps/web/app/api/milestones/[id]/route.ts b/apps/web/app/api/milestones/[id]/route.ts new file mode 100644 index 0000000..b6e9bf4 --- /dev/null +++ b/apps/web/app/api/milestones/[id]/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth, createErrorResponse } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; +import { updateMilestoneSchema } from '@project-e/shared'; +import { z } from 'zod'; + +type RouteContext = { params: Promise<{ id: string }> }; + +// GET /api/milestones/[id] — Get a single milestone +export const GET = withAuth(async (request: NextRequest, _user, context) => { + const { id } = await context!.params; + + const pb = createPocketBaseClient(); + const milestone = await pb.collection('milestones').getOne(id); + + return NextResponse.json(milestone); +}); + +// PATCH /api/milestones/[id] — Update a milestone +export const PATCH = withAuth(async (request: NextRequest, _user, context) => { + try { + const { id } = await context!.params; + const body = await request.json(); + const data = updateMilestoneSchema.parse(body); + + const pb = createPocketBaseClient(); + const milestone = await pb.collection('milestones').update(id, data); + + return NextResponse.json(milestone); + } catch (error) { + if (error instanceof z.ZodError) { + return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues); + } + throw error; + } +}); + +// DELETE /api/milestones/[id] — Delete a milestone +export const DELETE = withAuth(async (request: NextRequest, _user, context) => { + const { id } = await context!.params; + + const pb = createPocketBaseClient(); + await pb.collection('milestones').delete(id); + + return new NextResponse(null, { status: 204 }); +}); diff --git a/apps/web/app/api/milestones/route.ts b/apps/web/app/api/milestones/route.ts new file mode 100644 index 0000000..1e0a7a2 --- /dev/null +++ b/apps/web/app/api/milestones/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth, createErrorResponse } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; +import { createMilestoneSchema } from '@project-e/shared'; +import { z } from 'zod'; + +// GET /api/milestones — List milestones with filtering, sorting, pagination +export const GET = withAuth(async (request: NextRequest, _user) => { + const { searchParams } = new URL(request.url); + const page = parseInt(searchParams.get('page') || '1'); + const perPage = parseInt(searchParams.get('perPage') || '50'); + const filter = searchParams.get('filter') || ''; + const sort = searchParams.get('sort') || '-created'; + + const pb = createPocketBaseClient(); + const result = await pb.collection('milestones').getList(page, perPage, { + filter, + sort, + }); + + const response = NextResponse.json({ + items: result.items, + totalItems: result.totalItems, + totalPages: result.totalPages, + page: result.page, + perPage: result.perPage, + }); + + response.headers.set( + 'Cache-Control', + 'private, max-age=60, stale-while-revalidate=300' + ); + + return response; +}); + +// POST /api/milestones — Create a milestone +export const POST = withAuth(async (request: NextRequest, _user) => { + try { + const body = await request.json(); + const data = createMilestoneSchema.parse(body); + + const pb = createPocketBaseClient(); + const milestone = await pb.collection('milestones').create(data); + + return NextResponse.json(milestone, { status: 201 }); + } catch (error) { + if (error instanceof z.ZodError) { + return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues); + } + throw error; + } +}); diff --git a/apps/web/app/api/notes/[id]/backlinks/route.ts b/apps/web/app/api/notes/[id]/backlinks/route.ts new file mode 100644 index 0000000..62891d7 --- /dev/null +++ b/apps/web/app/api/notes/[id]/backlinks/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth } from '@/lib/auth'; +import { getBacklinks } from '@/lib/services/note-service'; + +type RouteContext = { params: Promise<{ id: string }> }; + +// GET /api/notes/[id]/backlinks — Get notes that link to this note +export const GET = withAuth( + async (request: NextRequest, _user, context) => { + const { id } = await context!.params; + const backlinks = await getBacklinks(id); + + return NextResponse.json({ + items: backlinks, + totalItems: backlinks.length, + }); + } +); diff --git a/apps/web/app/api/notes/[id]/route.ts b/apps/web/app/api/notes/[id]/route.ts new file mode 100644 index 0000000..a9d8e45 --- /dev/null +++ b/apps/web/app/api/notes/[id]/route.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth, createErrorResponse } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; +import { updateNoteSchema } from '@project-e/shared'; +import { syncNoteLinks, syncNoteTasks, getBacklinks } from '@/lib/services'; +import { z } from 'zod'; + +type RouteContext = { params: Promise<{ id: string }> }; + +// GET /api/notes/[id] — Get a single note with backlinks +export const GET = withAuth(async (request: NextRequest, _user, context) => { + const { id } = await context!.params; + + const pb = createPocketBaseClient(); + const note = await pb.collection('notes').getOne(id); + const backlinks = await getBacklinks(id); + + return NextResponse.json({ + ...note, + backlinks, + }); +}); + +// PATCH /api/notes/[id] — Update a note, then re-sync links and tasks +export const PATCH = withAuth(async (request: NextRequest, _user, context) => { + try { + const { id } = await context!.params; + const body = await request.json(); + const data = updateNoteSchema.parse(body); + + const pb = createPocketBaseClient(); + const note = await pb.collection('notes').update(id, data); + + // Re-sync wikilinks and checkbox tasks from content + const content = data.content ?? note.content; + if (content) { + await syncNoteLinks(id, content); + await syncNoteTasks(id, content); + } + + return NextResponse.json(note); + } catch (error) { + if (error instanceof z.ZodError) { + return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues); + } + throw error; + } +}); + +// DELETE /api/notes/[id] — Delete a note +export const DELETE = withAuth(async (request: NextRequest, _user, context) => { + const { id } = await context!.params; + + const pb = createPocketBaseClient(); + await pb.collection('notes').delete(id); + + return new NextResponse(null, { status: 204 }); +}); diff --git a/apps/web/app/api/notes/daily/route.ts b/apps/web/app/api/notes/daily/route.ts new file mode 100644 index 0000000..f70d893 --- /dev/null +++ b/apps/web/app/api/notes/daily/route.ts @@ -0,0 +1,236 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth, createErrorResponse } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/** Parse a "YYYY-MM-DD" string into start/end ISO boundaries (UTC). */ +function dayBounds(dateStr: string) { + const start = new Date(`${dateStr}T00:00:00.000Z`); + const end = new Date(`${dateStr}T23:59:59.999Z`); + return { start: start.toISOString(), end: end.toISOString() }; +} + +/** Format minutes into a human-readable "Xh Ym" string. */ +function formatMinutes(total: number): string { + if (total < 60) return `${total}m`; + const h = Math.floor(total / 60); + const m = total % 60; + return m > 0 ? `${h}h ${m}m` : `${h}h`; +} + +/** Escape HTML special characters. */ +function esc(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +/** Build an
    of items, or an empty-state

    if the list is empty. */ +function list(items: string[], emptyMsg: string): string { + if (items.length === 0) { + return `

    ${esc(emptyMsg)}

    `; + } + return `
      ${items.map((t) => `
    • ${t}
    • `).join('')}
    `; +} + +/** Generate the full HTML body for a daily note. */ +function buildDailyNoteHtml(ctx: { + completedTasks: string[]; + habitLogs: string[]; + timeEntries: string[]; + overdueTasks: string[]; +}): string { + return [ + `

    Tasks Completed

    `, + list(ctx.completedTasks, 'No tasks completed today.'), + `

    Habits Logged

    `, + list(ctx.habitLogs, 'No habits logged today.'), + `

    Time Tracked

    `, + list(ctx.timeEntries, 'No time tracked today.'), + `

    Overdue Items

    `, + list(ctx.overdueTasks, 'Nothing overdue.'), + `

    Notes

    `, + `

    `, + `

    Reflections

    `, + `

    `, + `

    Gratitude

    `, + `

    `, + ].join('\n'); +} + +// ── Route handlers ─────────────────────────────────────────────────────────── + +/** GET /api/notes/daily?date=YYYY-MM-DD — return the daily note if it exists. */ +export const GET = withAuth(async (request: NextRequest) => { + const { searchParams } = new URL(request.url); + const date = searchParams.get('date'); + + if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) { + return createErrorResponse( + 'VALIDATION_ERROR', + 'A valid date parameter (YYYY-MM-DD) is required.', + 400 + ); + } + + const title = `Daily Note - ${date}`; + const pb = createPocketBaseClient(); + + const result = await pb.collection('notes').getList(1, 1, { + filter: `title = "${title}"`, + }); + + if (result.items.length === 0) { + return NextResponse.json({ note: null }); + } + + return NextResponse.json({ note: result.items[0] }); +}); + +/** POST /api/notes/daily — create today's daily note (idempotent). */ +export const POST = withAuth(async (request: NextRequest) => { + try { + const body = await request.json(); + const date: string | undefined = body?.date; + + if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) { + return createErrorResponse( + 'VALIDATION_ERROR', + 'A valid date string (YYYY-MM-DD) is required in the request body.', + 400 + ); + } + + const title = `Daily Note - ${date}`; + const pb = createPocketBaseClient(); + + // ── 1. Idempotency check ──────────────────────────────────────────────── + const existing = await pb.collection('notes').getList(1, 1, { + filter: `title = "${title}"`, + }); + if (existing.items.length > 0) { + return NextResponse.json(existing.items[0]); + } + + // ── 2. Date boundaries ────────────────────────────────────────────────── + const { start, end } = dayBounds(date); + + // ── 3. Fetch all data in parallel ─────────────────────────────────────── + const [ + completedTaskRecords, + habitLogRecords, + timeEntryRecords, + overdueTaskRecords, + habitsAll, + ] = await Promise.all([ + // Tasks completed today + pb.collection('tasks').getFullList({ + filter: `completed_at >= "${start}" && completed_at <= "${end}"`, + sort: 'completed_at', + }), + // Habit logs for the day + pb.collection('habit_logs').getFullList({ + filter: `logged_at >= "${start}" && logged_at <= "${end}"`, + sort: 'logged_at', + }), + // Time entries for the day + pb.collection('task_time_entries').getFullList({ + filter: `started_at >= "${start}" && started_at <= "${end}"`, + sort: 'started_at', + }), + // Overdue tasks (due before today, not done) + pb.collection('tasks').getFullList({ + filter: `due_date < "${start}" && status != "done" && status != "cancelled"`, + sort: 'due_date', + }), + // All active habits (for name lookup) + pb.collection('habits').getFullList({ + filter: 'active = true', + }), + ]); + + // ── 4. Build lookup maps ──────────────────────────────────────────────── + const habitNameById = new Map(); + for (const h of habitsAll) { + habitNameById.set(h.id, h.name as string); + } + + // Collect task IDs from time entries so we can resolve names + const taskIdsForTimeEntries = [ + ...new Set(timeEntryRecords.map((e) => e.task_id as string)), + ]; + const taskNamesMap = new Map(); + + // Fetch task names in parallel for time entries and overdue tasks + const allTaskIds = new Set(); + for (const t of completedTaskRecords) allTaskIds.add(t.id); + for (const t of overdueTaskRecords) allTaskIds.add(t.id); + for (const id of taskIdsForTimeEntries) allTaskIds.add(id); + + const taskFetches = await Promise.allSettled( + [...allTaskIds].map((id) => pb.collection('tasks').getOne(id)) + ); + for (const res of taskFetches) { + if (res.status === 'fulfilled') { + const t = res.value; + taskNamesMap.set(t.id, t.title as string); + } + } + + // ── 5. Format sections ────────────────────────────────────────────────── + const completedTasks = completedTaskRecords.map((t) => { + const name = taskNamesMap.get(t.id) ?? (t.title as string); + return `${esc(name)}`; + }); + + const habitLogs = habitLogRecords.map((log) => { + const habitName = habitNameById.get(log.habit_id) ?? 'Unknown habit'; + const status = log.completed ? '✓' : log.skipped ? 'skipped' : '—'; + const mood = log.mood != null ? ` (mood: ${log.mood}/5)` : ''; + return `${esc(habitName)} — ${status}${mood}`; + }); + + const timeEntries = timeEntryRecords.map((entry) => { + const taskName = taskNamesMap.get(entry.task_id as string) ?? 'Unknown task'; + const dur = formatMinutes((entry.duration_minutes as number) || 0); + const notes = entry.notes ? ` — ${esc(entry.notes as string)}` : ''; + return `${dur} on ${esc(taskName)}${notes}`; + }); + + const overdueTasks = overdueTaskRecords.map((t) => { + const name = taskNamesMap.get(t.id) ?? (t.title as string); + const due = t.due_date + ? ` (due ${new Date(t.due_date as string).toLocaleDateString()})` + : ''; + return `${esc(name)}${due}`; + }); + + // ── 6. Build HTML content ─────────────────────────────────────────────── + const content = buildDailyNoteHtml({ + completedTasks, + habitLogs, + timeEntries, + overdueTasks, + }); + + // ── 7. Create note ────────────────────────────────────────────────────── + const note = await pb.collection('notes').create({ + title, + content, + domain: 'personal', + tags: ['daily'], + }); + + return NextResponse.json(note, { status: 201 }); + } catch (error) { + console.error('Failed to create daily note:', error); + return createErrorResponse( + 'INTERNAL_ERROR', + 'Failed to create daily note.', + 500 + ); + } +}); diff --git a/apps/web/app/api/notes/graph/route.ts b/apps/web/app/api/notes/graph/route.ts new file mode 100644 index 0000000..9a3d851 --- /dev/null +++ b/apps/web/app/api/notes/graph/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server'; +import { withAuth } from '@/lib/auth'; +import { getNoteGraph } from '@/lib/services/note-service'; + +// GET /api/notes/graph — Get note graph data for visualization +export const GET = withAuth(async () => { + const graph = await getNoteGraph(); + return NextResponse.json(graph, { + headers: { + 'Cache-Control': 'private, max-age=60, stale-while-revalidate=300', + }, + }); +}); diff --git a/apps/web/app/api/notes/route.ts b/apps/web/app/api/notes/route.ts new file mode 100644 index 0000000..ead1b65 --- /dev/null +++ b/apps/web/app/api/notes/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth, createErrorResponse } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; +import { createNoteSchema } from '@project-e/shared'; +import { syncNoteLinks, syncNoteTasks } from '@/lib/services'; +import { z } from 'zod'; + +// GET /api/notes — List notes with filtering, sorting, pagination +export const GET = withAuth(async (request: NextRequest, _user) => { + const { searchParams } = new URL(request.url); + const page = parseInt(searchParams.get('page') || '1'); + const perPage = parseInt(searchParams.get('perPage') || '50'); + const filter = searchParams.get('filter') || ''; + const sort = searchParams.get('sort') || '-created'; + + const pb = createPocketBaseClient(); + const result = await pb.collection('notes').getList(page, perPage, { + filter, + sort, + }); + + const response = NextResponse.json({ + items: result.items, + totalItems: result.totalItems, + totalPages: result.totalPages, + page: result.page, + perPage: result.perPage, + }); + + // Cache for 60 seconds with stale-while-revalidate + response.headers.set( + 'Cache-Control', + 'private, max-age=60, stale-while-revalidate=300' + ); + + return response; +}); + +// POST /api/notes — Create a note, then sync links and tasks +export const POST = withAuth(async (request: NextRequest, _user) => { + try { + const body = await request.json(); + const data = createNoteSchema.parse(body); + + const pb = createPocketBaseClient(); + const note = await pb.collection('notes').create(data); + + // Sync wikilinks and checkbox tasks from content + if (data.content) { + await syncNoteLinks(note.id, data.content); + await syncNoteTasks(note.id, data.content); + } + + return NextResponse.json(note, { status: 201 }); + } catch (error) { + if (error instanceof z.ZodError) { + return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues); + } + throw error; + } +}); diff --git a/apps/web/app/api/projects/[id]/progress/route.ts b/apps/web/app/api/projects/[id]/progress/route.ts new file mode 100644 index 0000000..b600fdf --- /dev/null +++ b/apps/web/app/api/projects/[id]/progress/route.ts @@ -0,0 +1,12 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth } from '@/lib/auth'; +import { computeProjectProgress } from '@/lib/services/project-service'; + +type RouteContext = { params: Promise<{ id: string }> }; + +// GET /api/projects/[id]/progress — Get project progress +export const GET = withAuth(async (_request: NextRequest, _user, context) => { + const { id } = await context!.params; + const progress = await computeProjectProgress(id); + return NextResponse.json({ progress }); +}); diff --git a/apps/web/app/api/projects/[id]/route.ts b/apps/web/app/api/projects/[id]/route.ts new file mode 100644 index 0000000..91113f6 --- /dev/null +++ b/apps/web/app/api/projects/[id]/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth, createErrorResponse } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; +import { updateProjectSchema } from '@project-e/shared'; +import { z } from 'zod'; + +type RouteContext = { params: Promise<{ id: string }> }; + +// GET /api/projects/[id] — Get a single project +export const GET = withAuth(async (request: NextRequest, _user, context) => { + const { id } = await context!.params; + + const pb = createPocketBaseClient(); + const project = await pb.collection('projects').getOne(id); + + return NextResponse.json(project); +}); + +// PATCH /api/projects/[id] — Update a project +export const PATCH = withAuth(async (request: NextRequest, _user, context) => { + try { + const { id } = await context!.params; + const body = await request.json(); + const data = updateProjectSchema.parse(body); + + const pb = createPocketBaseClient(); + const project = await pb.collection('projects').update(id, data); + + return NextResponse.json(project); + } catch (error) { + if (error instanceof z.ZodError) { + return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues); + } + throw error; + } +}); + +// DELETE /api/projects/[id] — Delete a project +export const DELETE = withAuth(async (request: NextRequest, _user, context) => { + const { id } = await context!.params; + + const pb = createPocketBaseClient(); + await pb.collection('projects').delete(id); + + return new NextResponse(null, { status: 204 }); +}); diff --git a/apps/web/app/api/projects/route.ts b/apps/web/app/api/projects/route.ts new file mode 100644 index 0000000..c6caa72 --- /dev/null +++ b/apps/web/app/api/projects/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth, createErrorResponse } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; +import { createProjectSchema } from '@project-e/shared'; +import { z } from 'zod'; + +// GET /api/projects — List projects with filtering, sorting, pagination +export const GET = withAuth(async (request: NextRequest, _user) => { + const { searchParams } = new URL(request.url); + const page = parseInt(searchParams.get('page') || '1'); + const perPage = parseInt(searchParams.get('perPage') || '50'); + const filter = searchParams.get('filter') || ''; + const sort = searchParams.get('sort') || '-created'; + + const pb = createPocketBaseClient(); + const result = await pb.collection('projects').getList(page, perPage, { + filter, + sort, + }); + + const response = NextResponse.json({ + items: result.items, + totalItems: result.totalItems, + totalPages: result.totalPages, + page: result.page, + perPage: result.perPage, + }); + + response.headers.set( + 'Cache-Control', + 'private, max-age=60, stale-while-revalidate=300' + ); + + return response; +}); + +// POST /api/projects — Create a project +export const POST = withAuth(async (request: NextRequest, _user) => { + try { + const body = await request.json(); + const data = createProjectSchema.parse(body); + + const pb = createPocketBaseClient(); + const project = await pb.collection('projects').create(data); + + return NextResponse.json(project, { status: 201 }); + } catch (error) { + if (error instanceof z.ZodError) { + return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues); + } + throw error; + } +}); diff --git a/apps/web/app/api/realtime/route.ts b/apps/web/app/api/realtime/route.ts new file mode 100644 index 0000000..960a144 --- /dev/null +++ b/apps/web/app/api/realtime/route.ts @@ -0,0 +1,111 @@ +import { NextRequest } from 'next/server'; +import { getAuthUser, getAuthToken } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; + +export const dynamic = 'force-dynamic'; +export const maxDuration = 300; // 5 minutes + +const DEFAULT_COLLECTIONS = [ + 'tasks', + 'habits', + 'projects', + 'notes', + 'reports', + 'milestones', + 'notifications', +]; + +// GET /api/realtime — Multiplexed SSE endpoint for PocketBase realtime subscriptions +export async function GET(request: NextRequest) { + const user = await getAuthUser(request); + if (!user) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Parse subscription preferences from query params + const { searchParams } = new URL(request.url); + const collectionsParam = searchParams.get('collections') || ''; + const collections = collectionsParam + .split(',') + .map((c) => c.trim()) + .filter(Boolean); + + const subscribedCollections = + collections.length > 0 ? collections : DEFAULT_COLLECTIONS; + + const token = getAuthToken(request); + const pb = createPocketBaseClient(token || undefined); + const encoder = new TextEncoder(); + const unsubscribeFns: Array<() => Promise> = []; + + const stream = new ReadableStream({ + async start(controller) { + // Send connected event + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ type: 'connected', collections: subscribedCollections })}\n\n` + ) + ); + + // Subscribe to each collection + for (const collection of subscribedCollections) { + try { + const unsub = await pb.collection(collection).subscribe('*', (e) => { + try { + const event = { + type: e.action, + collection, + record: e.record, + }; + controller.enqueue( + encoder.encode(`data: ${JSON.stringify(event)}\n\n`) + ); + } catch { + // Controller might be closed + } + }); + unsubscribeFns.push(unsub); + } catch { + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ type: 'subscription_error', collection })}\n\n` + ) + ); + } + } + + // Keepalive ping every 30 seconds + const keepalive = setInterval(() => { + try { + controller.enqueue(encoder.encode(':ping\n\n')); + } catch { + clearInterval(keepalive); + } + }, 30000); + }, + + async cancel() { + // Client disconnected — cleanup all subscriptions + for (const unsub of unsubscribeFns) { + try { + await unsub(); + } catch { + // Ignore cleanup errors + } + } + unsubscribeFns.length = 0; + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }, + }); +} diff --git a/apps/web/app/api/reports/[id]/route.ts b/apps/web/app/api/reports/[id]/route.ts new file mode 100644 index 0000000..90ea272 --- /dev/null +++ b/apps/web/app/api/reports/[id]/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth, createErrorResponse } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; +import { updateReportSchema } from '@project-e/shared'; +import { z } from 'zod'; + +type RouteContext = { params: Promise<{ id: string }> }; + +// GET /api/reports/[id] — Get a single report +export const GET = withAuth(async (request: NextRequest, _user, context) => { + const { id } = await context!.params; + + const pb = createPocketBaseClient(); + const report = await pb.collection('reports').getOne(id); + + return NextResponse.json(report); +}); + +// PATCH /api/reports/[id] — Update a report +export const PATCH = withAuth(async (request: NextRequest, _user, context) => { + try { + const { id } = await context!.params; + const body = await request.json(); + const data = updateReportSchema.parse(body); + + const pb = createPocketBaseClient(); + const report = await pb.collection('reports').update(id, data); + + return NextResponse.json(report); + } catch (error) { + if (error instanceof z.ZodError) { + return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues); + } + throw error; + } +}); + +// DELETE /api/reports/[id] — Delete a report +export const DELETE = withAuth(async (request: NextRequest, _user, context) => { + const { id } = await context!.params; + + const pb = createPocketBaseClient(); + await pb.collection('reports').delete(id); + + return new NextResponse(null, { status: 204 }); +}); diff --git a/apps/web/app/api/reports/route.ts b/apps/web/app/api/reports/route.ts new file mode 100644 index 0000000..c25ee90 --- /dev/null +++ b/apps/web/app/api/reports/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth, createErrorResponse } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; +import { createReportSchema } from '@project-e/shared'; +import { z } from 'zod'; + +// GET /api/reports — List reports with filtering, sorting, pagination +export const GET = withAuth(async (request: NextRequest, _user) => { + const { searchParams } = new URL(request.url); + const page = parseInt(searchParams.get('page') || '1'); + const perPage = parseInt(searchParams.get('perPage') || '50'); + const filter = searchParams.get('filter') || ''; + const sort = searchParams.get('sort') || '-created'; + + const pb = createPocketBaseClient(); + const result = await pb.collection('reports').getList(page, perPage, { + filter, + sort, + }); + + return NextResponse.json({ + items: result.items, + totalItems: result.totalItems, + totalPages: result.totalPages, + page: result.page, + perPage: result.perPage, + }); +}); + +// POST /api/reports — Create a report +export const POST = withAuth(async (request: NextRequest, _user) => { + try { + const body = await request.json(); + const data = createReportSchema.parse(body); + + const pb = createPocketBaseClient(); + const report = await pb.collection('reports').create(data); + + return NextResponse.json(report, { status: 201 }); + } catch (error) { + if (error instanceof z.ZodError) { + return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues); + } + throw error; + } +}); diff --git a/apps/web/app/api/search/route.ts b/apps/web/app/api/search/route.ts new file mode 100644 index 0000000..a9e12ed --- /dev/null +++ b/apps/web/app/api/search/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; + +// GET /api/search — Cross-entity full-text search +export const GET = withAuth(async (request: NextRequest, _user) => { + const { searchParams } = new URL(request.url); + const query = searchParams.get('q') || ''; + const types = searchParams.get('types')?.split(',') || ['tasks', 'habits', 'projects', 'notes', 'reports']; + const limit = parseInt(searchParams.get('limit') || '10'); + + if (!query.trim()) { + return NextResponse.json({ results: [] }); + } + + // Escape double quotes in query to prevent filter injection + const safeQuery = query.replace(/"/g, '\\"'); + + const pb = createPocketBaseClient(); + const results: Array<{ type: string; items: unknown[] }> = []; + + for (const type of types) { + try { + let filter = ''; + + switch (type) { + case 'tasks': + filter = `title ~ "${safeQuery}" || description ~ "${safeQuery}"`; + break; + case 'habits': + filter = `name ~ "${safeQuery}" || description ~ "${safeQuery}"`; + break; + case 'projects': + filter = `name ~ "${safeQuery}" || description ~ "${safeQuery}"`; + break; + case 'notes': + filter = `title ~ "${safeQuery}" || content ~ "${safeQuery}"`; + break; + case 'reports': + filter = `title ~ "${safeQuery}" || content ~ "${safeQuery}"`; + break; + default: + continue; + } + + const items = await pb.collection(type).getList(1, limit, { filter }); + results.push({ type, items: items.items }); + } catch { + // Skip collections that fail (e.g. missing or inaccessible) + } + } + + return NextResponse.json({ results }); +}); diff --git a/apps/web/app/api/tags/[id]/route.ts b/apps/web/app/api/tags/[id]/route.ts new file mode 100644 index 0000000..effed16 --- /dev/null +++ b/apps/web/app/api/tags/[id]/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth, createErrorResponse } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; +import { updateTagSchema } from '@project-e/shared'; +import { z } from 'zod'; + +type RouteContext = { params: Promise<{ id: string }> }; + +// GET /api/tags/[id] — Get a single tag +export const GET = withAuth(async (request: NextRequest, _user, context) => { + const { id } = await context!.params; + + const pb = createPocketBaseClient(); + const tag = await pb.collection('tags').getOne(id); + + return NextResponse.json(tag); +}); + +// PATCH /api/tags/[id] — Update a tag +export const PATCH = withAuth(async (request: NextRequest, _user, context) => { + try { + const { id } = await context!.params; + const body = await request.json(); + const data = updateTagSchema.parse(body); + + const pb = createPocketBaseClient(); + const tag = await pb.collection('tags').update(id, data); + + return NextResponse.json(tag); + } catch (error) { + if (error instanceof z.ZodError) { + return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues); + } + throw error; + } +}); + +// DELETE /api/tags/[id] — Delete a tag +export const DELETE = withAuth(async (request: NextRequest, _user, context) => { + const { id } = await context!.params; + + const pb = createPocketBaseClient(); + await pb.collection('tags').delete(id); + + return new NextResponse(null, { status: 204 }); +}); diff --git a/apps/web/app/api/tags/route.ts b/apps/web/app/api/tags/route.ts new file mode 100644 index 0000000..b9d650d --- /dev/null +++ b/apps/web/app/api/tags/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth, createErrorResponse } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; +import { createTagSchema } from '@project-e/shared'; +import { z } from 'zod'; + +// GET /api/tags — List tags with filtering, sorting, pagination +export const GET = withAuth(async (request: NextRequest, _user) => { + const { searchParams } = new URL(request.url); + const page = parseInt(searchParams.get('page') || '1'); + const perPage = parseInt(searchParams.get('perPage') || '50'); + const filter = searchParams.get('filter') || ''; + const sort = searchParams.get('sort') || 'name'; + + const pb = createPocketBaseClient(); + const result = await pb.collection('tags').getList(page, perPage, { + filter, + sort, + }); + + return NextResponse.json({ + items: result.items, + totalItems: result.totalItems, + totalPages: result.totalPages, + page: result.page, + perPage: result.perPage, + }); +}); + +// POST /api/tags — Create a tag +export const POST = withAuth(async (request: NextRequest, _user) => { + try { + const body = await request.json(); + const data = createTagSchema.parse(body); + + const pb = createPocketBaseClient(); + const tag = await pb.collection('tags').create(data); + + return NextResponse.json(tag, { status: 201 }); + } catch (error) { + if (error instanceof z.ZodError) { + return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues); + } + throw error; + } +}); diff --git a/apps/web/app/api/tasks/[id]/route.ts b/apps/web/app/api/tasks/[id]/route.ts new file mode 100644 index 0000000..fb9cc7a --- /dev/null +++ b/apps/web/app/api/tasks/[id]/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth, createErrorResponse } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; +import { updateTaskSchema } from '@project-e/shared'; +import { z } from 'zod'; + +type RouteContext = { params: Promise<{ id: string }> }; + +// GET /api/tasks/[id] — Get a single task +export const GET = withAuth(async (request: NextRequest, _user, context) => { + const { id } = await context!.params; + + const pb = createPocketBaseClient(); + const task = await pb.collection('tasks').getOne(id); + + return NextResponse.json(task); +}); + +// PATCH /api/tasks/[id] — Update a task +export const PATCH = withAuth(async (request: NextRequest, _user, context) => { + try { + const { id } = await context!.params; + const body = await request.json(); + const data = updateTaskSchema.parse(body); + + const pb = createPocketBaseClient(); + const task = await pb.collection('tasks').update(id, data); + + return NextResponse.json(task); + } catch (error) { + if (error instanceof z.ZodError) { + return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues); + } + throw error; + } +}); + +// DELETE /api/tasks/[id] — Delete a task +export const DELETE = withAuth(async (request: NextRequest, _user, context) => { + const { id } = await context!.params; + + const pb = createPocketBaseClient(); + await pb.collection('tasks').delete(id); + + return new NextResponse(null, { status: 204 }); +}); diff --git a/apps/web/app/api/tasks/bulk/route.ts b/apps/web/app/api/tasks/bulk/route.ts new file mode 100644 index 0000000..0aec219 --- /dev/null +++ b/apps/web/app/api/tasks/bulk/route.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth, createErrorResponse } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; +import { z } from 'zod'; + +const bulkCreateSchema = z.object({ + tasks: z.array(z.object({ + title: z.string().min(1), + description: z.string().optional(), + status: z.enum(['todo', 'in_progress', 'done', 'cancelled']).optional(), + priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(), + due_date: z.string().optional(), + project_id: z.string().optional(), + domain: z.string(), + tags: z.array(z.string()).optional(), + })).min(1).max(100), +}); + +const bulkUpdateSchema = z.object({ + ids: z.array(z.string()).min(1), + updates: z.object({ + status: z.enum(['todo', 'in_progress', 'done', 'cancelled']).optional(), + priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(), + project_id: z.string().optional(), + domain: z.string().optional(), + }), +}); + +const bulkDeleteSchema = z.object({ + ids: z.array(z.string()).min(1), +}); + +// POST /api/tasks/bulk — Bulk create/update/delete +export const POST = withAuth(async (request: NextRequest, _user) => { + const body = await request.json(); + const pb = createPocketBaseClient(); + + // Determine operation from body shape + if ('tasks' in body) { + // Bulk create + const data = bulkCreateSchema.parse(body); + const created = []; + for (const task of data.tasks) { + const result = await pb.collection('tasks').create(task); + created.push(result); + } + return NextResponse.json({ created: created.length, items: created }, { status: 201 }); + } + + if ('ids' in body && 'updates' in body) { + // Bulk update + const data = bulkUpdateSchema.parse(body); + const updated = []; + for (const id of data.ids) { + const result = await pb.collection('tasks').update(id, data.updates); + updated.push(result); + } + return NextResponse.json({ updated: updated.length, items: updated }); + } + + if ('ids' in body) { + // Bulk delete + const data = bulkDeleteSchema.parse(body); + for (const id of data.ids) { + await pb.collection('tasks').delete(id); + } + return NextResponse.json({ deleted: data.ids.length }); + } + + return createErrorResponse('INVALID_REQUEST', 'Must specify tasks, ids+updates, or ids', 400); +}); diff --git a/apps/web/app/api/tasks/route.ts b/apps/web/app/api/tasks/route.ts new file mode 100644 index 0000000..d3d0ed8 --- /dev/null +++ b/apps/web/app/api/tasks/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth, createErrorResponse } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; +import { createTaskSchema } from '@project-e/shared'; +import { z } from 'zod'; + +// GET /api/tasks — List tasks with filtering, sorting, pagination +export const GET = withAuth(async (request: NextRequest, _user) => { + const { searchParams } = new URL(request.url); + const page = parseInt(searchParams.get('page') || '1'); + const perPage = parseInt(searchParams.get('perPage') || '50'); + const filter = searchParams.get('filter') || ''; + const sort = searchParams.get('sort') || '-created'; + + const pb = createPocketBaseClient(); + const result = await pb.collection('tasks').getList(page, perPage, { + filter, + sort, + }); + + const response = NextResponse.json({ + items: result.items, + totalItems: result.totalItems, + totalPages: result.totalPages, + page: result.page, + perPage: result.perPage, + }); + + response.headers.set( + 'Cache-Control', + 'private, max-age=60, stale-while-revalidate=300' + ); + + return response; +}); + +// POST /api/tasks — Create a task +export const POST = withAuth(async (request: NextRequest, _user) => { + try { + const body = await request.json(); + const data = createTaskSchema.parse(body); + + const pb = createPocketBaseClient(); + const task = await pb.collection('tasks').create(data); + + return NextResponse.json(task, { status: 201 }); + } catch (error) { + if (error instanceof z.ZodError) { + return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues); + } + throw error; + } +}); diff --git a/apps/web/app/api/time-summary/route.ts b/apps/web/app/api/time-summary/route.ts new file mode 100644 index 0000000..d6ae853 --- /dev/null +++ b/apps/web/app/api/time-summary/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; + +// GET /api/time-summary — Aggregated time by domain/project/tag +export const GET = withAuth(async (request: NextRequest, _user) => { + const { searchParams } = new URL(request.url); + const startDate = searchParams.get('start') || new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); + const endDate = searchParams.get('end') || new Date().toISOString(); + + const pb = createPocketBaseClient(); + + const entries = await pb.collection('task_time_entries').getFullList({ + filter: `started_at >= "${startDate}" && started_at <= "${endDate}"`, + }); + + const byDomain: Record = {}; + const byProject: Record = {}; + const byTag: Record = {}; + let totalMinutes = 0; + + for (const entry of entries) { + const duration = (entry.duration_minutes as number) || 0; + totalMinutes += duration; + + // Get task for domain/project/tags + const task = await pb.collection('tasks').getOne(entry.task_id as string); + + const domain = task.domain as string; + byDomain[domain] = (byDomain[domain] || 0) + duration; + + const projectId = task.project_id as string | undefined; + if (projectId) { + byProject[projectId] = (byProject[projectId] || 0) + duration; + } + + const tags = (task.tags as string[]) || []; + for (const tag of tags) { + byTag[tag] = (byTag[tag] || 0) + duration; + } + } + + return NextResponse.json({ + totalMinutes, + byDomain, + byProject, + byTag, + startDate, + endDate, + }, { + headers: { + 'Cache-Control': 'private, max-age=300, stale-while-revalidate=600', + }, + }); +}); diff --git a/apps/web/app/api/webhook-deliveries/[id]/retry/route.ts b/apps/web/app/api/webhook-deliveries/[id]/retry/route.ts new file mode 100644 index 0000000..8be9b7a --- /dev/null +++ b/apps/web/app/api/webhook-deliveries/[id]/retry/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth, createErrorResponse } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; + +type RouteContext = { params: Promise<{ id: string }> }; + +// POST /api/webhook-deliveries/[id]/retry — Manually retry a failed delivery +export const POST = withAuth(async (request: NextRequest, _user, context) => { + const { id } = await context!.params; + + const pb = createPocketBaseClient(); + + // Get the failed delivery + const delivery = await pb.collection('webhook_deliveries').getOne(id); + + if (delivery.status === 'success') { + return createErrorResponse('INVALID_STATE', 'Cannot retry a successful delivery', 400); + } + + // Get the webhook to get the URL + const webhook = await pb.collection('webhooks').getOne(delivery.webhook_id); + + // Create a new queue job for retry + await pb.collection('queue_jobs').create({ + queue: 'webhooks', + type: 'webhook_delivery', + payload: { + webhook_id: webhook.id, + webhook_url: webhook.url, + webhook_secret: webhook.secret || '', + event_type: delivery.event_type, + event_payload: delivery.payload, + }, + status: 'pending', + retry_count: 0, + max_attempts: 3, + scheduled_at: new Date().toISOString(), + }); + + // Update the delivery status to pending + await pb.collection('webhook_deliveries').update(id, { + status: 'pending', + }); + + return NextResponse.json({ success: true, message: 'Retry queued' }); +}); diff --git a/apps/web/app/api/webhook-deliveries/route.ts b/apps/web/app/api/webhook-deliveries/route.ts new file mode 100644 index 0000000..310e0f0 --- /dev/null +++ b/apps/web/app/api/webhook-deliveries/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; + +// GET /api/webhook-deliveries — List webhook deliveries with filtering +export const GET = withAuth(async (request: NextRequest, _user) => { + const { searchParams } = new URL(request.url); + const page = parseInt(searchParams.get('page') || '1'); + const perPage = parseInt(searchParams.get('perPage') || '50'); + const filter = searchParams.get('filter') || ''; + const sort = searchParams.get('sort') || '-created'; + const webhookId = searchParams.get('webhook_id') || ''; + + const pb = createPocketBaseClient(); + + let combinedFilter = filter; + if (webhookId) { + combinedFilter = combinedFilter + ? `${combinedFilter} && webhook_id = "${webhookId}"` + : `webhook_id = "${webhookId}"`; + } + + const result = await pb.collection('webhook_deliveries').getList(page, perPage, { + filter: combinedFilter, + sort, + }); + + return NextResponse.json({ + items: result.items, + totalItems: result.totalItems, + totalPages: result.totalPages, + page: result.page, + perPage: result.perPage, + }); +}); diff --git a/apps/web/app/api/webhooks/[id]/route.ts b/apps/web/app/api/webhooks/[id]/route.ts new file mode 100644 index 0000000..766f908 --- /dev/null +++ b/apps/web/app/api/webhooks/[id]/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth, createErrorResponse } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; +import { updateWebhookSchema } from '@project-e/shared'; +import { z } from 'zod'; + +type RouteContext = { params: Promise<{ id: string }> }; + +// GET /api/webhooks/[id] — Get a single webhook +export const GET = withAuth(async (request: NextRequest, _user, context) => { + const { id } = await context!.params; + + const pb = createPocketBaseClient(); + const webhook = await pb.collection('webhooks').getOne(id); + + return NextResponse.json(webhook); +}); + +// PATCH /api/webhooks/[id] — Update a webhook +export const PATCH = withAuth(async (request: NextRequest, _user, context) => { + try { + const { id } = await context!.params; + const body = await request.json(); + const data = updateWebhookSchema.parse(body); + + const pb = createPocketBaseClient(); + const webhook = await pb.collection('webhooks').update(id, data); + + return NextResponse.json(webhook); + } catch (error) { + if (error instanceof z.ZodError) { + return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues); + } + throw error; + } +}); + +// DELETE /api/webhooks/[id] — Delete a webhook +export const DELETE = withAuth(async (request: NextRequest, _user, context) => { + const { id } = await context!.params; + + const pb = createPocketBaseClient(); + await pb.collection('webhooks').delete(id); + + return new NextResponse(null, { status: 204 }); +}); diff --git a/apps/web/app/api/webhooks/[id]/test/route.ts b/apps/web/app/api/webhooks/[id]/test/route.ts new file mode 100644 index 0000000..55a21e1 --- /dev/null +++ b/apps/web/app/api/webhooks/[id]/test/route.ts @@ -0,0 +1,93 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth, createErrorResponse } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; + +type RouteContext = { params: Promise<{ id: string }> }; + +// POST /api/webhooks/[id]/test — Send a test event to the webhook +export const POST = withAuth(async (request: NextRequest, _user, context) => { + const { id } = await context!.params; + + const pb = createPocketBaseClient(); + + // Get the webhook + const webhook = await pb.collection('webhooks').getOne(id); + + if (!webhook.active) { + return createErrorResponse('WEBHOOK_DISABLED', 'Cannot test a disabled webhook', 400); + } + + // Create a test payload + const testPayload = { + event: 'test.ping', + timestamp: new Date().toISOString(), + data: { + message: 'This is a test webhook delivery from Project E.', + webhook_id: webhook.id, + webhook_name: webhook.name, + }, + }; + + // Create HMAC signature if secret is provided + const headers: Record = { + 'Content-Type': 'application/json', + 'X-Event-Type': 'test.ping', + }; + + if (webhook.secret) { + const crypto = await import('node:crypto'); + const body = JSON.stringify(testPayload); + const signature = crypto + .createHmac('sha256', webhook.secret) + .update(body) + .digest('hex'); + headers['X-Webhook-Signature'] = signature; + } + + try { + const response = await fetch(webhook.url, { + method: 'POST', + headers, + body: JSON.stringify(testPayload), + signal: AbortSignal.timeout(10000), + }); + + const responseBody = await response.text(); + + // Record the delivery + await pb.collection('webhook_deliveries').create({ + webhook_id: webhook.id, + event_type: 'test.ping', + payload: testPayload as Record, + success: response.ok, + response_status: response.status, + response_body: responseBody.substring(0, 1000), + attempts: 1, + }); + + return NextResponse.json({ + success: response.ok, + status: response.status, + response: responseBody.substring(0, 500), + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + + // Record the failed delivery + await pb.collection('webhook_deliveries').create({ + webhook_id: webhook.id, + event_type: 'test.ping', + payload: testPayload as Record, + success: false, + response_status: 0, + response_body: errorMessage, + attempts: 1, + }); + + return NextResponse.json({ + success: false, + status: 0, + response: errorMessage, + }); + } +}); diff --git a/apps/web/app/api/webhooks/route.ts b/apps/web/app/api/webhooks/route.ts new file mode 100644 index 0000000..6c35347 --- /dev/null +++ b/apps/web/app/api/webhooks/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth, createErrorResponse } from '@/lib/auth'; +import { createPocketBaseClient } from '@/lib/pocketbase'; +import { createWebhookSchema } from '@project-e/shared'; +import { z } from 'zod'; + +// GET /api/webhooks — List webhooks with filtering, sorting, pagination +export const GET = withAuth(async (request: NextRequest, _user) => { + const { searchParams } = new URL(request.url); + const page = parseInt(searchParams.get('page') || '1'); + const perPage = parseInt(searchParams.get('perPage') || '50'); + const filter = searchParams.get('filter') || ''; + const sort = searchParams.get('sort') || '-created'; + + const pb = createPocketBaseClient(); + const result = await pb.collection('webhooks').getList(page, perPage, { + filter, + sort, + }); + + return NextResponse.json({ + items: result.items, + totalItems: result.totalItems, + totalPages: result.totalPages, + page: result.page, + perPage: result.perPage, + }); +}); + +// POST /api/webhooks — Create a webhook +export const POST = withAuth(async (request: NextRequest, _user) => { + try { + const body = await request.json(); + const data = createWebhookSchema.parse(body); + + const pb = createPocketBaseClient(); + const webhook = await pb.collection('webhooks').create(data); + + return NextResponse.json(webhook, { status: 201 }); + } catch (error) { + if (error instanceof z.ZodError) { + return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues); + } + throw error; + } +}); diff --git a/apps/web/app/error.tsx b/apps/web/app/error.tsx new file mode 100644 index 0000000..da252d4 --- /dev/null +++ b/apps/web/app/error.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error('Application error:', error); + }, [error]); + + return ( +
    + + + Something went wrong + + +

    + An unexpected error occurred. Please try again or contact support + if the problem persists. +

    + {error.digest && ( +

    + Error ID: {error.digest} +

    + )} +
    + + + + +
    +
    + ); +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css new file mode 100644 index 0000000..957207a --- /dev/null +++ b/apps/web/app/globals.css @@ -0,0 +1,332 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + /* shadcn base variables — mapped from current design */ + --background: 80 10% 95%; + --foreground: 150 15% 10%; + --card: 0 0% 100%; + --card-foreground: 150 15% 10%; + --popover: 0 0% 100%; + --popover-foreground: 150 15% 10%; + --primary: 224 100% 60%; + --primary-foreground: 0 0% 100%; + --secondary: 80 8% 93%; + --secondary-foreground: 150 15% 10%; + --muted: 80 8% 93%; + --muted-foreground: 140 5% 40%; + --accent: 80 8% 93%; + --accent-foreground: 150 15% 10%; + --destructive: 14 76% 62%; + --destructive-foreground: 0 0% 100%; + --border: 110 5% 89%; + --input: 110 5% 89%; + --ring: 224 100% 60%; + --radius: 18px; + + /* Project E custom variables */ + --sidebar: 150 25% 11%; + --sidebar-foreground: 140 20% 93%; + --sidebar-accent: 150 15% 18%; + --green: 150 50% 28%; + --coral: 14 76% 62%; + --amber: 37 65% 53%; + --domain-personal: 150 55% 37%; + --domain-work: 224 100% 60%; + --domain-ots: 14 80% 63%; + } + + .dark { + --background: 150 15% 7%; + --foreground: 80 10% 95%; + --card: 150 15% 10%; + --card-foreground: 80 10% 95%; + --popover: 150 15% 10%; + --popover-foreground: 80 10% 95%; + --primary: 224 100% 65%; + --primary-foreground: 0 0% 100%; + --secondary: 150 10% 15%; + --secondary-foreground: 80 10% 95%; + --muted: 150 10% 15%; + --muted-foreground: 140 5% 60%; + --accent: 150 10% 15%; + --accent-foreground: 80 10% 95%; + --destructive: 14 76% 55%; + --destructive-foreground: 0 0% 100%; + --border: 150 10% 18%; + --input: 150 10% 18%; + --ring: 224 100% 65%; + + --sidebar: 150 25% 8%; + --sidebar-foreground: 140 20% 90%; + --sidebar-accent: 150 15% 14%; + --green: 150 50% 35%; + --coral: 14 76% 55%; + --amber: 37 65% 45%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + font-family: var(--font-geist-sans), system-ui, sans-serif; + } +} + +/* Density variants */ +[data-density='compact'] { + --spacing-unit: 0.75rem; + --radius: 12px; +} + +[data-density='comfortable'] { + --spacing-unit: 1rem; + --radius: 18px; +} + +[data-density='spacious'] { + --spacing-unit: 1.25rem; + --radius: 22px; +} + +/* Reduced motion */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +[data-reduced-motion='true'] * { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; +} + +/* Skip link for accessibility */ +.skip-link { + position: fixed; + left: 12px; + top: -50px; + z-index: 200; + background: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); + padding: 10px 14px; + border-radius: 8px; + font-weight: 600; + text-decoration: none; + transition: top 0.2s; +} +.skip-link:focus { + top: 12px; + outline: 2px solid hsl(var(--ring)); + outline-offset: 2px; +} + +/* React Calendar Heatmap */ +.react-calendar-heatmap rect { + rx: 2; + ry: 2; +} + +.heatmap-empty { + fill: hsl(var(--muted)); +} + +.heatmap-scale-1 { + fill: hsl(var(--primary) / 0.25); +} + +.heatmap-scale-2 { + fill: hsl(var(--primary) / 0.5); +} + +.heatmap-scale-3 { + fill: hsl(var(--primary) / 0.75); +} + +.heatmap-scale-4 { + fill: hsl(var(--primary)); +} + +.react-calendar-heatmap .react-calendar-heatmap-month-label, +.react-calendar-heatmap .react-calendar-heatmap-weekday-label { + font-size: 0.625rem; + fill: hsl(var(--muted-foreground)); +} + +/* TipTap Editor */ +.tiptap-editor .tiptap { + outline: none; + min-height: 400px; +} + +.tiptap-editor .tiptap p { + margin-bottom: 0.75rem; + line-height: 1.7; +} + +.tiptap-editor .tiptap h1, +.tiptap-editor .tiptap h2, +.tiptap-editor .tiptap h3 { + font-weight: 600; + margin-top: 1.5rem; + margin-bottom: 0.75rem; +} + +.tiptap-editor .tiptap h1 { + font-size: 1.5rem; +} + +.tiptap-editor .tiptap h2 { + font-size: 1.25rem; +} + +.tiptap-editor .tiptap h3 { + font-size: 1.125rem; +} + +.tiptap-editor .tiptap ul { + list-style-type: disc; + padding-left: 1.5rem; + margin-bottom: 0.75rem; +} + +.tiptap-editor .tiptap ol { + list-style-type: decimal; + padding-left: 1.5rem; + margin-bottom: 0.75rem; +} + +.tiptap-editor .tiptap li { + margin-bottom: 0.25rem; +} + +.tiptap-editor .tiptap blockquote { + border-left: 3px solid hsl(var(--border)); + padding-left: 1rem; + margin-left: 0; + margin-bottom: 0.75rem; + color: hsl(var(--muted-foreground)); +} + +.tiptap-editor .tiptap code { + background: hsl(var(--muted)); + border-radius: 4px; + padding: 0.15rem 0.35rem; + font-family: var(--font-geist-mono), monospace; + font-size: 0.875em; +} + +.tiptap-editor .tiptap pre { + background: hsl(var(--muted)); + border-radius: 8px; + padding: 0.75rem 1rem; + margin-bottom: 0.75rem; + overflow-x: auto; +} + +.tiptap-editor .tiptap pre code { + background: none; + padding: 0; +} + +.tiptap-editor .tiptap a { + color: hsl(var(--primary)); + text-decoration: underline; + text-underline-offset: 2px; +} + +.tiptap-editor .tiptap a:hover { + opacity: 0.8; +} + +.tiptap-editor .tiptap hr { + border: none; + border-top: 1px solid hsl(var(--border)); + margin: 1.5rem 0; +} + +.tiptap-editor .tiptap p.is-editor-empty:first-child::before { + content: attr(data-placeholder); + float: left; + color: hsl(var(--muted-foreground)); + pointer-events: none; + height: 0; +} + +/* React Big Calendar */ +.rbc-calendar { + font-family: inherit; +} + +.rbc-toolbar { + margin-bottom: 1em; +} + +.rbc-toolbar button { + color: hsl(var(--foreground)); + background-color: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + border-radius: 0.375rem; + padding: 0.375rem 0.75rem; + font-size: 0.875rem; + font-weight: 500; + transition: all 0.15s; +} + +.rbc-toolbar button:hover { + background-color: hsl(var(--accent)); +} + +.rbc-toolbar button.rbc-active { + background-color: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); + border-color: hsl(var(--primary)); +} + +.rbc-month-view, +.rbc-time-view { + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; +} + +.rbc-header { + padding: 0.5rem; + font-weight: 600; + border-bottom: 1px solid hsl(var(--border)); + background-color: hsl(var(--muted)); +} + +.rbc-day-bg { + background-color: hsl(var(--background)); +} + +.rbc-off-range-bg { + background-color: hsl(var(--muted) / 0.5); +} + +.rbc-today { + background-color: hsl(var(--primary) / 0.1); +} + +.rbc-event { + padding: 2px 5px; + font-size: 12px; +} + +.rbc-show-more { + color: hsl(var(--primary)); + font-size: 12px; + font-weight: 500; +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx new file mode 100644 index 0000000..a16db28 --- /dev/null +++ b/apps/web/app/layout.tsx @@ -0,0 +1,45 @@ +import type { Metadata, Viewport } from 'next'; +import { Geist, Geist_Mono } from 'next/font/google'; +import { ThemeProvider } from '@/components/theme-provider'; +import { Toaster } from '@/components/ui/sonner'; +import './globals.css'; + +const geistSans = Geist({ + variable: '--font-geist-sans', + subsets: ['latin'], +}); + +const geistMono = Geist_Mono({ + variable: '--font-geist-mono', + subsets: ['latin'], +}); + +export const metadata: Metadata = { + title: 'Project E — Your personal operating system', + description: + 'Tasks, habits, projects, notes, reports, and agents in one calm workspace.', +}; + +export const viewport: Viewport = { + width: 'device-width', + initialScale: 1, + themeColor: [ + { media: '(prefers-color-scheme: light)', color: '#f2f3ef' }, + { media: '(prefers-color-scheme: dark)', color: '#17241e' }, + ], +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + + ); +} diff --git a/apps/web/app/not-found.tsx b/apps/web/app/not-found.tsx new file mode 100644 index 0000000..0300730 --- /dev/null +++ b/apps/web/app/not-found.tsx @@ -0,0 +1,32 @@ +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; + +export default function NotFound() { + return ( +
    + + + Page not found + + +

    + The page you're looking for doesn't exist or has been + moved. +

    +
    + + + +
    +
    + ); +} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx new file mode 100644 index 0000000..f889cb6 --- /dev/null +++ b/apps/web/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation'; + +export default function Home() { + redirect('/dashboard'); +} diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 0000000..c597462 --- /dev/null +++ b/apps/web/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/apps/web/components/analytics/analytics-charts.tsx b/apps/web/components/analytics/analytics-charts.tsx new file mode 100644 index 0000000..408e447 --- /dev/null +++ b/apps/web/components/analytics/analytics-charts.tsx @@ -0,0 +1,345 @@ +'use client'; + +import { + TrendingUp, + Clock, + Flame, + BarChart3, + PieChart as PieChartIcon, +} from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + LineChart, + Line, + BarChart, + Bar, + PieChart, + Pie, + Cell, + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, +} from 'recharts'; + +interface TimeData { + date: string; + tasks: number; + habits: number; + time: number; +} + +interface DomainData { + name: string; + value: number; + color: string; +} + +interface HabitData { + name: string; + streak: number; + score: number; + consistency: number; +} + +const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#06b6d4']; + +const chartTooltipStyle = { + backgroundColor: 'hsl(var(--card))', + border: '1px solid hsl(var(--border))', + borderRadius: '0.5rem', +}; + +interface AnalyticsChartsProps { + timeData: TimeData[]; + domainData: DomainData[]; + habitData: HabitData[]; + activeTab: string; +} + +export function AnalyticsCharts({ + timeData, + domainData, + habitData, + activeTab, +}: AnalyticsChartsProps) { + if (activeTab === 'trends') { + return ( +
    + {/* Productivity trend */} + + + + + + + + + + + + + + + + + + + + + {/* Time tracked trend */} + + + + + + + + + + + + + + + + + +
    + ); + } + + if (activeTab === 'habits') { + return ( +
    + {/* Habit streaks */} + + + + + + + {habitData.length === 0 ? ( +

    + No habits tracked +

    + ) : ( + + + + + + + + + + )} +
    +
    + + {/* Habit scores */} + + + Habit Scores + + + {habitData.length === 0 ? ( +

    + No habits tracked +

    + ) : ( +
    + {habitData.map((habit) => ( +
    +
    + + {habit.name} + + + {habit.score}/100 + +
    +
    +
    +
    +
    + ))} +
    + )} + + +
    + ); + } + + if (activeTab === 'time') { + return ( +
    + {/* Time by domain */} + + + + + + + {domainData.length === 0 ? ( +

    + No time tracked +

    + ) : ( +
    + + + + `${name}: ${((percent || 0) * 100).toFixed(0)}%` + } + outerRadius={100} + fill="#8884d8" + dataKey="value" + > + {domainData.map((entry, index) => ( + + ))} + + + + +
    + )} +
    +
    + + {/* Daily breakdown */} + + + + + + + + + + + + + + + + + + + +
    + ); + } + + return null; +} diff --git a/apps/web/components/calendar/big-calendar-wrapper.tsx b/apps/web/components/calendar/big-calendar-wrapper.tsx new file mode 100644 index 0000000..519298c --- /dev/null +++ b/apps/web/components/calendar/big-calendar-wrapper.tsx @@ -0,0 +1,67 @@ +'use client'; + +import { Calendar, dateFnsLocalizer } from 'react-big-calendar'; +import 'react-big-calendar/lib/css/react-big-calendar.css'; +import { format, parse, startOfWeek, getDay } from 'date-fns'; +import { enUS } from 'date-fns/locale/en-US'; + +const locales = { + 'en-US': enUS, +}; + +const localizer = dateFnsLocalizer({ + format, + parse, + startOfWeek, + getDay, + locales, +}); + +interface CalendarEvent { + id: string; + title: string; + start: Date; + end: Date; + type: 'task' | 'habit' | 'project' | 'milestone'; + domain: string; + color: string; +} + +interface BigCalendarWrapperProps { + events: CalendarEvent[]; +} + +function eventStyleGetter(event: CalendarEvent) { + return { + style: { + backgroundColor: event.color, + borderRadius: '4px', + opacity: 0.8, + color: 'white', + border: '0px', + fontSize: '12px', + }, + }; +} + +function handleSelectEvent(event: CalendarEvent) { + console.log('Selected event:', event); +} + +export function BigCalendarWrapper({ events }: BigCalendarWrapperProps) { + return ( + + ); +} diff --git a/apps/web/components/command-palette.tsx b/apps/web/components/command-palette.tsx new file mode 100644 index 0000000..3c1f35b --- /dev/null +++ b/apps/web/components/command-palette.tsx @@ -0,0 +1,205 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { + LayoutDashboard, + ListTodo, + Flame, + FolderKanban, + NotebookPen, + FileBarChart, + CalendarDays, + BarChart3, + Bot, + Settings, + Plus, + Search, + type LucideIcon, +} from 'lucide-react'; +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from '@/components/ui/command'; + +interface NavItem { + label: string; + href: string; + icon: LucideIcon; +} + +const navItems: NavItem[] = [ + { label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard }, + { label: 'Tasks', href: '/tasks', icon: ListTodo }, + { label: 'Habits', href: '/habits', icon: Flame }, + { label: 'Projects', href: '/projects', icon: FolderKanban }, + { label: 'Notes', href: '/notes', icon: NotebookPen }, + { label: 'Reports', href: '/reports', icon: FileBarChart }, + { label: 'Calendar', href: '/calendar', icon: CalendarDays }, + { label: 'Analytics', href: '/analytics', icon: BarChart3 }, + { label: 'Agent Activity', href: '/agents', icon: Bot }, + { label: 'Settings', href: '/settings', icon: Settings }, +]; + +interface QuickAction { + label: string; + shortcut?: string; + action: () => void; +} + +export function CommandPalette() { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [deepSearch, setDeepSearch] = useState(false); + const [searchResults, setSearchResults] = useState; + }>>([]); + + // Keyboard shortcuts + useEffect(() => { + const down = (e: KeyboardEvent) => { + if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + if (e.shiftKey) { + setDeepSearch(true); + setOpen(true); + } else { + setDeepSearch(false); + setOpen(true); + } + } + }; + + document.addEventListener('keydown', down); + return () => document.removeEventListener('keydown', down); + }, []); + + // Quick actions + const quickActions: QuickAction[] = [ + { label: 'New task', shortcut: 'N', action: () => router.push('/tasks?new=true') }, + { label: 'New habit', action: () => router.push('/habits?new=true') }, + { label: 'New project', action: () => router.push('/projects?new=true') }, + { label: 'New note', action: () => router.push('/notes?new=true') }, + { label: 'New report', action: () => router.push('/reports?new=true') }, + ]; + + // Search handler + const handleSearch = useCallback(async (query: string) => { + if (!query.trim()) { + setSearchResults([]); + return; + } + + try { + const response = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=5`); + if (response.ok) { + const data = await response.json(); + setSearchResults(data.results || []); + } + } catch { + // Ignore search errors + } + }, []); + + const runCommand = useCallback((command: () => void) => { + setOpen(false); + command(); + }, []); + + return ( + + + + No results found. + + {/* Navigation */} + {!deepSearch && ( + + {navItems.map((item) => ( + runCommand(() => router.push(item.href))} + > + + {item.label} + + ))} + + )} + + {/* Quick Actions */} + + {quickActions.map((action) => ( + runCommand(action.action)} + > + + {action.label} + {action.shortcut && ( + + {action.shortcut} + + )} + + ))} + + + {/* Search Results (deep search mode) */} + {deepSearch && searchResults.length > 0 && ( + <> + + {searchResults.map((group) => ( + + {group.items.map((item) => ( + { + const typeRoute = + group.type === 'tasks' ? '/tasks' : + group.type === 'habits' ? '/habits' : + group.type === 'projects' ? '/projects' : + group.type === 'notes' ? '/notes' : + '/reports'; + runCommand(() => router.push(`${typeRoute}/${item.id}`)); + }} + > + + {item.title} + + ))} + + ))} + + )} + + {/* Footer hint */} +
    + + ↑↓ navigate + + + select + + + esc close + +
    +
    +
    + ); +} diff --git a/apps/web/components/dashboard/responsive-grid-layout.tsx b/apps/web/components/dashboard/responsive-grid-layout.tsx new file mode 100644 index 0000000..50cb938 --- /dev/null +++ b/apps/web/components/dashboard/responsive-grid-layout.tsx @@ -0,0 +1,51 @@ +'use client'; + +import ReactGridLayout from 'react-grid-layout'; +import 'react-grid-layout/css/styles.css'; +import 'react-resizable/css/styles.css'; + +// WidthProvider and Responsive are namespace exports from react-grid-layout. +// With @types/react-grid-layout's `export =` pattern, we access them via the module. +const WidthProvider = ( + ReactGridLayout as unknown as { + WidthProvider:

    >>( + component: P + ) => React.ComponentType & { measureBeforeMount?: boolean }>; + } +).WidthProvider; + +const Responsive = ( + ReactGridLayout as unknown as { + Responsive: React.ComponentType; + } +).Responsive; + +const ResponsiveGridLayout = WidthProvider(Responsive); + +interface ResponsiveGridProps { + layout: ReactGridLayout.Layout[]; + onLayoutChange: (newLayout: ReactGridLayout.Layout[]) => void; + children: React.ReactNode; +} + +export default function ResponsiveGrid({ + layout, + onLayoutChange, + children, +}: ResponsiveGridProps) { + return ( + + {children} + + ); +} diff --git a/apps/web/components/dashboard/widgets/calendar-mini-widget.tsx b/apps/web/components/dashboard/widgets/calendar-mini-widget.tsx new file mode 100644 index 0000000..b975676 --- /dev/null +++ b/apps/web/components/dashboard/widgets/calendar-mini-widget.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { Calendar } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; + +export function CalendarMiniWidget() { + const today = new Date(); + const daysInMonth = new Date( + today.getFullYear(), + today.getMonth() + 1, + 0 + ).getDate(); + const firstDay = new Date( + today.getFullYear(), + today.getMonth(), + 1 + ).getDay(); + + return ( + + + + + + +

    + {['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((day, i) => ( +
    + {day} +
    + ))} + {Array.from({ length: firstDay }).map((_, i) => ( +
    + ))} + {Array.from({ length: daysInMonth }).map((_, i) => { + const day = i + 1; + const isToday = day === today.getDate(); + return ( +
    + {day} +
    + ); + })} +
    + + + ); +} diff --git a/apps/web/components/dashboard/widgets/habit-checklist-widget.tsx b/apps/web/components/dashboard/widgets/habit-checklist-widget.tsx new file mode 100644 index 0000000..a35b8a3 --- /dev/null +++ b/apps/web/components/dashboard/widgets/habit-checklist-widget.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Flame } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; + +interface Habit { + id: string; + name: string; + current_streak: number; + logged_today: boolean; +} + +export function HabitChecklistWidget() { + const [habits, setHabits] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchHabits(); + }, []); + + async function fetchHabits() { + try { + const response = await fetch('/api/habits'); + if (response.ok) { + const data = await response.json(); + setHabits(data.items || []); + } + } catch (error) { + console.error('Failed to fetch habits:', error); + } finally { + setLoading(false); + } + } + + async function toggleHabit(id: string) { + try { + await fetch(`/api/habits/${id}/logs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + fetchHabits(); + } catch (error) { + console.error('Failed to log habit:', error); + } + } + + const completedCount = habits.filter((h) => h.logged_today).length; + + return ( + + +
    + + + + {completedCount}/{habits.length} done + +
    +
    + + {loading ? ( +

    Loading...

    + ) : habits.length === 0 ? ( +

    No habits tracked

    + ) : ( +
    + {habits.slice(0, 5).map((habit) => ( +
    + toggleHabit(habit.id)} + aria-label={`Mark "${habit.name}" as ${habit.logged_today ? 'incomplete' : 'complete'}`} + /> + + {habit.current_streak > 0 && ( + + 🔥 {habit.current_streak} + + )} +
    + ))} +
    + )} +
    +
    + ); +} diff --git a/apps/web/components/dashboard/widgets/habit-streaks-widget.tsx b/apps/web/components/dashboard/widgets/habit-streaks-widget.tsx new file mode 100644 index 0000000..d897b7d --- /dev/null +++ b/apps/web/components/dashboard/widgets/habit-streaks-widget.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Flame } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; + +interface Streak { + habit: { name: string }; + streak_current: number; +} + +export function HabitStreaksWidget() { + const [streaks, setStreaks] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchStreaks(); + }, []); + + async function fetchStreaks() { + try { + const response = await fetch('/api/habits/streaks'); + if (response.ok) { + const data = await response.json(); + setStreaks(data.streaks || []); + } + } catch (error) { + console.error('Failed to fetch streaks:', error); + } finally { + setLoading(false); + } + } + + return ( + + + + + + + {loading ? ( +

    Loading...

    + ) : streaks.length === 0 ? ( +

    No active streaks

    + ) : ( +
    + {streaks.slice(0, 5).map((streak, i) => ( +
    + {streak.habit.name} + + 🔥 {streak.streak_current} + +
    + ))} +
    + )} +
    +
    + ); +} diff --git a/apps/web/components/dashboard/widgets/project-progress-widget.tsx b/apps/web/components/dashboard/widgets/project-progress-widget.tsx new file mode 100644 index 0000000..fa0de3c --- /dev/null +++ b/apps/web/components/dashboard/widgets/project-progress-widget.tsx @@ -0,0 +1,69 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { FolderKanban } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Progress } from '@/components/ui/progress'; + +interface Project { + id: string; + name: string; + progress: number; +} + +export function ProjectProgressWidget() { + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchProjects(); + }, []); + + async function fetchProjects() { + try { + const response = await fetch( + '/api/projects?filter=status%3D%22active%22&perPage=5' + ); + if (response.ok) { + const data = await response.json(); + setProjects(data.items || []); + } + } catch (error) { + console.error('Failed to fetch projects:', error); + } finally { + setLoading(false); + } + } + + return ( + + + + + + + {loading ? ( +

    Loading...

    + ) : projects.length === 0 ? ( +

    No active projects

    + ) : ( +
    + {projects.map((project) => ( +
    +
    + {project.name} + + {project.progress}% + +
    + +
    + ))} +
    + )} +
    +
    + ); +} diff --git a/apps/web/components/dashboard/widgets/quick-add-widget.tsx b/apps/web/components/dashboard/widgets/quick-add-widget.tsx new file mode 100644 index 0000000..3d885ba --- /dev/null +++ b/apps/web/components/dashboard/widgets/quick-add-widget.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { Plus } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; + +export function QuickAddWidget() { + function handleQuickAdd() { + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'k', metaKey: true }) + ); + } + + return ( + + + + + + +
    + + + +
    +
    +
    + ); +} diff --git a/apps/web/components/dashboard/widgets/recent-activity-widget.tsx b/apps/web/components/dashboard/widgets/recent-activity-widget.tsx new file mode 100644 index 0000000..7d472da --- /dev/null +++ b/apps/web/components/dashboard/widgets/recent-activity-widget.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { Activity } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; + +export function RecentActivityWidget() { + return ( + + + + + + +

    + Activity feed coming soon +

    +
    +
    + ); +} diff --git a/apps/web/components/dashboard/widgets/today-tasks-widget.tsx b/apps/web/components/dashboard/widgets/today-tasks-widget.tsx new file mode 100644 index 0000000..109a060 --- /dev/null +++ b/apps/web/components/dashboard/widgets/today-tasks-widget.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { CheckCircle2, Circle, ListTodo } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; + +interface Task { + id: string; + title: string; + status: string; + priority: string; + domain: string; +} + +export function TodayTasksWidget() { + const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchTasks(); + }, []); + + async function fetchTasks() { + try { + const response = await fetch( + '/api/tasks?filter=status!%3D%22done%22&perPage=5&sort=-priority' + ); + if (response.ok) { + const data = await response.json(); + setTasks(data.items || []); + } + } catch (error) { + console.error('Failed to fetch tasks:', error); + } finally { + setLoading(false); + } + } + + async function toggleTask(id: string, currentStatus: string) { + const newStatus = currentStatus === 'done' ? 'todo' : 'done'; + try { + await fetch(`/api/tasks/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: newStatus }), + }); + fetchTasks(); + } catch (error) { + console.error('Failed to toggle task:', error); + } + } + + return ( + + +
    + + + +
    +
    + + {loading ? ( +

    Loading...

    + ) : tasks.length === 0 ? ( +

    No tasks for today

    + ) : ( +
    + {tasks.map((task) => ( +
    + + + {task.title} + + + {task.domain} + +
    + ))} +
    + )} +
    +
    + ); +} diff --git a/apps/web/components/dashboard/widgets/weekly-stats-widget.tsx b/apps/web/components/dashboard/widgets/weekly-stats-widget.tsx new file mode 100644 index 0000000..360c703 --- /dev/null +++ b/apps/web/components/dashboard/widgets/weekly-stats-widget.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { BarChart3, TrendingUp } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; + +interface WeeklyStats { + taskCompletionRate: number; + habitConsistency: number; + totalTimeMinutes: number; +} + +export function WeeklyStatsWidget() { + const [stats, setStats] = useState({ + taskCompletionRate: 0, + habitConsistency: 0, + totalTimeMinutes: 0, + }); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchStats(); + }, []); + + async function fetchStats() { + try { + const response = await fetch('/api/analytics?period=7'); + if (response.ok) { + const data = await response.json(); + setStats(data); + } + } catch (error) { + console.error('Failed to fetch stats:', error); + } finally { + setLoading(false); + } + } + + return ( + + + + + + + {loading ? ( +

    Loading...

    + ) : ( +
    +
    + + Task completion + +
    + + {stats.taskCompletionRate}% + + +
    +
    +
    + + Habit consistency + + + {stats.habitConsistency}% + +
    +
    + + Time tracked + + + {Math.round(stats.totalTimeMinutes / 60)}h + +
    +
    + )} +
    +
    + ); +} diff --git a/apps/web/components/habits/habit-card.tsx b/apps/web/components/habits/habit-card.tsx new file mode 100644 index 0000000..4ffd259 --- /dev/null +++ b/apps/web/components/habits/habit-card.tsx @@ -0,0 +1,97 @@ +'use client'; + +import { Flame, CheckCircle2, Circle } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Progress } from '@/components/ui/progress'; + +interface HabitCardProps { + habit: { + id: string; + name: string; + description?: string; + frequency: 'daily' | 'weekly' | 'custom'; + current_streak: number; + best_streak: number; + score: number; + completion_mode: 'quick' | 'detailed'; + domain: string; + logged_today: boolean; + }; + onComplete: () => void; +} + +export function HabitCard({ habit, onComplete }: HabitCardProps) { + return ( + + +
    +
    + {habit.name} + {habit.description && ( +

    + {habit.description} +

    + )} +
    + + {habit.domain} + +
    +
    + + {/* Streak info */} +
    +
    +
    + + Best: {habit.best_streak} + +
    + + {/* Score */} +
    +
    + Score + {habit.score}/100 +
    + +
    + + {/* Frequency badge */} +
    + + {habit.frequency} + + + {habit.completion_mode} + +
    + + {/* Complete button */} + +
    +
    + ); +} diff --git a/apps/web/components/habits/habit-completion-dialog.tsx b/apps/web/components/habits/habit-completion-dialog.tsx new file mode 100644 index 0000000..e71fb48 --- /dev/null +++ b/apps/web/components/habits/habit-completion-dialog.tsx @@ -0,0 +1,128 @@ +'use client'; + +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; + +interface Habit { + id: string; + name: string; +} + +interface HabitCompletionDialogProps { + habit: Habit; + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (data: { mood?: number; value?: number; notes?: string }) => void; +} + +const moods = [ + { value: 5, label: 'Great' }, + { value: 4, label: 'Good' }, + { value: 3, label: 'Okay' }, + { value: 2, label: 'Meh' }, + { value: 1, label: 'Bad' }, +]; + +export function HabitCompletionDialog({ + habit, + open, + onOpenChange, + onSubmit, +}: HabitCompletionDialogProps) { + const [mood, setMood] = useState(); + const [quantity, setQuantity] = useState(); + const [notes, setNotes] = useState(''); + + function handleSubmit() { + onSubmit({ + mood, + value: quantity, + notes: notes || undefined, + }); + setMood(undefined); + setQuantity(undefined); + setNotes(''); + } + + return ( + + + + Log {habit.name} + + How did it go? (optional — you can skip and just log completion) + + + +
    + {/* Mood picker */} +
    + +
    + {moods.map((m) => ( + + ))} +
    +
    + + {/* Quantity */} +
    + + + setQuantity(e.target.value ? Number(e.target.value) : undefined) + } + /> +
    + + {/* Notes */} +
    + +