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
This commit is contained in:
@@ -0,0 +1,14 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
.turbo
|
||||||
|
.git
|
||||||
|
*.md
|
||||||
|
.env*
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
coverage
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
pocketbase/pb_data
|
||||||
@@ -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
|
||||||
+34
-31
@@ -1,42 +1,45 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
# dependencies
|
# Build outputs
|
||||||
/node_modules
|
.next/
|
||||||
/.pnp
|
dist/
|
||||||
.pnp.*
|
build/
|
||||||
.yarn/*
|
.turbo/
|
||||||
!.yarn/patches
|
|
||||||
!.yarn/plugins
|
|
||||||
!.yarn/releases
|
|
||||||
!.yarn/versions
|
|
||||||
|
|
||||||
# testing
|
# Environment
|
||||||
/coverage
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
# next.js
|
# Testing
|
||||||
/.next/
|
coverage/
|
||||||
/.vinext/
|
test-results/
|
||||||
/out/
|
playwright-report/
|
||||||
|
|
||||||
# misc
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
Thumbs.db
|
||||||
|
|
||||||
# debug
|
# Debug
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
.pnpm-debug.log*
|
|
||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# PocketBase
|
||||||
.env*
|
pocketbase/pb_data/
|
||||||
|
|
||||||
# vercel
|
# Temp
|
||||||
.vercel
|
*.tmp
|
||||||
|
.wrangler/
|
||||||
# typescript
|
.vinext/
|
||||||
next-env.d.ts
|
|
||||||
/dist/
|
|
||||||
/.wrangler/
|
|
||||||
/outputs/
|
|
||||||
/work/
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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"]
|
||||||
@@ -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"]
|
||||||
@@ -1,98 +1,352 @@
|
|||||||
# vinext-starter
|
# Project E
|
||||||
|
|
||||||
A clean full-stack starter running on
|
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.
|
||||||
[vinext](https://github.com/cloudflare/vinext), with optional Cloudflare D1 and
|
|
||||||
Drizzle support.
|
## 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
|
## 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
|
## Quick Start
|
||||||
|
|
||||||
|
### Development Setup
|
||||||
|
|
||||||
|
1. **Clone the repository**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
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
|
```bash
|
||||||
npm install
|
# Build and start all containers
|
||||||
npm run dev
|
docker compose up -d
|
||||||
npm run build
|
|
||||||
|
# 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
|
project-e/
|
||||||
- `vite.config.ts` simulates declared bindings for local development
|
├── apps/
|
||||||
- `db/schema.ts` starts intentionally empty
|
│ └── web/ # Next.js application
|
||||||
- `examples/d1/` contains an optional D1 example surface
|
│ ├── app/ # App Router pages and API routes
|
||||||
- `drizzle.config.ts` supports local migration generation when needed
|
│ │ ├── (auth)/ # Auth pages (login, signup)
|
||||||
|
│ │ ├── (dashboard)/ # Dashboard pages
|
||||||
## Workspace Auth Headers
|
│ │ ├── api/ # REST API endpoints
|
||||||
|
│ │ │ ├── auth/ # Login, logout, refresh, me
|
||||||
OpenAI workspace sites can read the current user's email from
|
│ │ │ ├── tasks/ # Task CRUD + bulk operations
|
||||||
`oai-authenticated-user-email`.
|
│ │ │ ├── habits/ # Habit CRUD
|
||||||
|
│ │ │ ├── projects/ # Project CRUD
|
||||||
SIWC-authenticated workspace sites may also receive
|
│ │ │ ├── notes/ # Note CRUD
|
||||||
`oai-authenticated-user-full-name` when the user's SIWC profile has a non-empty
|
│ │ │ ├── reports/ # Report CRUD
|
||||||
`name` claim. The full-name value is percent-encoded UTF-8 and is accompanied by
|
│ │ │ ├── milestones/ # Milestone CRUD
|
||||||
`oai-authenticated-user-full-name-encoding: percent-encoded-utf-8`.
|
│ │ │ ├── domains/ # Domain CRUD
|
||||||
|
│ │ │ ├── tags/ # Tag CRUD
|
||||||
Treat the full name as optional and fall back to email when it is absent:
|
│ │ │ ├── agents/ # Agent CRUD
|
||||||
|
│ │ │ ├── webhooks/ # Webhook CRUD
|
||||||
```tsx
|
│ │ │ ├── analytics/ # Analytics data
|
||||||
import { headers } from "next/headers";
|
│ │ │ ├── realtime/ # SSE proxy for PocketBase
|
||||||
|
│ │ │ ├── mcp/ # MCP server endpoint
|
||||||
export default async function Home() {
|
│ │ │ └── health/ # Health check
|
||||||
const requestHeaders = await headers();
|
│ │ └── layout.tsx # Root layout
|
||||||
const email = requestHeaders.get("oai-authenticated-user-email");
|
│ ├── components/ # React components (shadcn/ui)
|
||||||
const encodedFullName = requestHeaders.get("oai-authenticated-user-full-name");
|
│ ├── hooks/ # Custom React hooks
|
||||||
const fullName =
|
│ ├── lib/ # Utilities and services
|
||||||
encodedFullName &&
|
│ │ ├── mcp/ # MCP server and tools
|
||||||
requestHeaders.get("oai-authenticated-user-full-name-encoding") ===
|
│ │ ├── services/ # Business logic services
|
||||||
"percent-encoded-utf-8"
|
│ │ ├── stores/ # Zustand stores
|
||||||
? decodeURIComponent(encodedFullName)
|
│ │ ├── events/ # Event bus
|
||||||
: null;
|
│ │ ├── auth.ts # Auth middleware
|
||||||
|
│ │ ├── pocketbase.ts # PocketBase client
|
||||||
const displayName = fullName ?? email;
|
│ │ └── 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
|
| Command | Description |
|
||||||
optional or required ChatGPT sign-in:
|
|---------|-------------|
|
||||||
|
| `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.
|
## Environment Variables
|
||||||
- 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.
|
|
||||||
|
|
||||||
Dispatch owns `/signin-with-chatgpt`, `/signout-with-chatgpt`, `/callback`, the
|
| Variable | Description | Default |
|
||||||
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
|
| `POCKETBASE_URL` | PocketBase server URL | `http://localhost:8090` |
|
||||||
anonymous-compatible.
|
| `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
|
Create a `.env.local` file in the root directory for local development.
|
||||||
Sites hosting platform's access policy controls for workspace-wide restrictions,
|
|
||||||
or enforce explicit server-side membership or allowlist checks.
|
|
||||||
|
|
||||||
Use SIWC for account pages, user-specific dashboards, saved records, and write
|
## Testing
|
||||||
actions tied to the current ChatGPT user. Leave public content anonymous.
|
|
||||||
|
|
||||||
## Useful Commands
|
### Unit and Component Tests
|
||||||
|
|
||||||
- `npm run dev`: start local development
|
```bash
|
||||||
- `npm run build`: verify the vinext build output
|
npm run test
|
||||||
- `npm test`: build the starter and verify its rendered loading skeleton
|
```
|
||||||
- `npm run db:generate`: generate Drizzle migrations after schema changes
|
|
||||||
|
|
||||||
## Learn More
|
Runs Jest tests across all packages. Tests are located in `tests/` and alongside components.
|
||||||
|
|
||||||
- [vinext Documentation](https://github.com/cloudflare/vinext)
|
### E2E Tests
|
||||||
- [Drizzle D1 Guide](https://orm.drizzle.team/docs/get-started/d1-new)
|
|
||||||
|
```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
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex min-h-screen items-center justify-center p-6">
|
||||||
|
<Card className="w-full max-w-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Project E</CardTitle>
|
||||||
|
<CardDescription>Sign in to your workspace</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
|
{loading ? 'Signing in...' : 'Sign in'}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, unknown>;
|
||||||
|
after_state?: Record<string, unknown>;
|
||||||
|
created: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentTask {
|
||||||
|
id: string;
|
||||||
|
agent_id: string;
|
||||||
|
task_type: string;
|
||||||
|
input: string;
|
||||||
|
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||||
|
output?: Record<string, unknown>;
|
||||||
|
created: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AgentsPage() {
|
||||||
|
const [agents, setAgents] = useState<Agent[]>([]);
|
||||||
|
const [activity, setActivity] = useState<AgentActivity[]>([]);
|
||||||
|
const [agentTasks, setAgentTasks] = useState<AgentTask[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(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 <CheckCircle2 className="h-4 w-4 text-green-600" />;
|
||||||
|
case 'failed':
|
||||||
|
return <XCircle className="h-4 w-4 text-red-600" />;
|
||||||
|
case 'in_progress':
|
||||||
|
return <Clock className="h-4 w-4 text-blue-600 animate-pulse" />;
|
||||||
|
default:
|
||||||
|
return <Clock className="h-4 w-4 text-muted-foreground" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActionLabel(action: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
create: 'Created',
|
||||||
|
update: 'Updated',
|
||||||
|
delete: 'Deleted',
|
||||||
|
complete: 'Completed',
|
||||||
|
assign: 'Assigned',
|
||||||
|
};
|
||||||
|
return labels[action] || action;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <p className="text-muted-foreground">Loading agent activity...</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold">Agent Activity</h1>
|
||||||
|
<p className="mt-1 text-muted-foreground">
|
||||||
|
Every agent action, visible and reversible.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[300px_1fr]">
|
||||||
|
{/* Agents list */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Agents</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{agents.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
No agents configured
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{agents.map((agent) => (
|
||||||
|
<button
|
||||||
|
key={agent.id}
|
||||||
|
onClick={() => setSelectedAgent(agent)}
|
||||||
|
aria-label={`View activity for agent: ${agent.name}`}
|
||||||
|
aria-current={selectedAgent?.id === agent.id ? 'true' : undefined}
|
||||||
|
className={`w-full rounded-lg p-3 text-left transition-colors ${
|
||||||
|
selectedAgent?.id === agent.id
|
||||||
|
? 'bg-accent'
|
||||||
|
: 'hover:bg-accent/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
<AvatarFallback>
|
||||||
|
{agent.name.charAt(0).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium">{agent.name}</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
variant={agent.status === 'active' ? 'default' : 'secondary'}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{agent.status}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{agent.permission_tier}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{agent.last_activity_at && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{new Date(agent.last_activity_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Activity feed */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Activity className="h-5 w-5" aria-hidden="true" />
|
||||||
|
Activity Feed
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Tabs defaultValue="activity">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="activity">Activity</TabsTrigger>
|
||||||
|
<TabsTrigger value="tasks">Tasks</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="activity" className="mt-4">
|
||||||
|
{activity.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-muted-foreground">
|
||||||
|
No agent activity yet
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{activity.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="rounded-lg border p-4 transition-colors hover:bg-accent/50"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
<AvatarFallback>
|
||||||
|
{getAgentName(item.agent_id).charAt(0).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">
|
||||||
|
{getAgentName(item.agent_id)}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{getActionLabel(item.action)}
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{item.entity_type}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{new Date(item.created).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
{item.before_state && item.after_state && (
|
||||||
|
<details className="mt-2">
|
||||||
|
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground" role="button">
|
||||||
|
View changes
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-red-600">Before</p>
|
||||||
|
<pre className="mt-1 rounded bg-muted p-2 overflow-x-auto">
|
||||||
|
{JSON.stringify(item.before_state, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-green-600">After</p>
|
||||||
|
<pre className="mt-1 rounded bg-muted p-2 overflow-x-auto">
|
||||||
|
{JSON.stringify(item.after_state, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => undoActivity(item.id)}
|
||||||
|
className="shrink-0"
|
||||||
|
aria-label={`Undo activity: ${getActionLabel(item.action)} ${item.entity_type}`}
|
||||||
|
>
|
||||||
|
<RotateCcw className="mr-1 h-3 w-3" aria-hidden="true" />
|
||||||
|
Undo
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="tasks" className="mt-4">
|
||||||
|
{agentTasks.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-muted-foreground">
|
||||||
|
No agent tasks yet
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{agentTasks.map((task) => (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
className="rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{getStatusIcon(task.status)}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">
|
||||||
|
{getAgentName(task.agent_id)}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
task.status === 'completed'
|
||||||
|
? 'default'
|
||||||
|
: task.status === 'failed'
|
||||||
|
? 'destructive'
|
||||||
|
: 'secondary'
|
||||||
|
}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{task.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm">{task.input}</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{new Date(task.created).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
{task.output && (
|
||||||
|
<details className="mt-2">
|
||||||
|
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground" role="button">
|
||||||
|
View output
|
||||||
|
</summary>
|
||||||
|
<pre className="mt-2 rounded bg-muted p-2 text-xs overflow-x-auto">
|
||||||
|
{JSON.stringify(task.output, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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: () => (
|
||||||
|
<div className="grid grid-cols-1 gap-6">
|
||||||
|
{[1, 2].map((i) => (
|
||||||
|
<div key={i} className="h-[350px] animate-pulse rounded-lg border bg-muted/30 p-6">
|
||||||
|
<div className="mb-4 h-5 w-40 rounded bg-muted/50" />
|
||||||
|
<div className="h-full rounded bg-muted/30" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
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<AnalyticsData | null>(null);
|
||||||
|
const [timeData, setTimeData] = useState<TimeData[]>([]);
|
||||||
|
const [domainData, setDomainData] = useState<DomainData[]>([]);
|
||||||
|
const [habitData, setHabitData] = useState<HabitData[]>([]);
|
||||||
|
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 <p className="text-muted-foreground">Loading analytics...</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold">Analytics</h1>
|
||||||
|
<p className="mt-1 text-muted-foreground">Patterns behind your progress.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary cards */}
|
||||||
|
<div className="mb-6 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Task Completion
|
||||||
|
</CardTitle>
|
||||||
|
<Target className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{analytics.taskCompletionRate}%
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Last {analytics.period} days
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Habit Consistency
|
||||||
|
</CardTitle>
|
||||||
|
<TrendingUp className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{analytics.habitConsistency}%
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Last {analytics.period} days
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Time Tracked</CardTitle>
|
||||||
|
<Clock className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{Math.round(analytics.totalTimeMinutes / 60)}h
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Last {analytics.period} days
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Active Streaks
|
||||||
|
</CardTitle>
|
||||||
|
<Flame className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{analytics.activeStreaks}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Best: {analytics.bestStreak} days
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts */}
|
||||||
|
<Tabs defaultValue="trends">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="trends">Trends</TabsTrigger>
|
||||||
|
<TabsTrigger value="habits">Habits</TabsTrigger>
|
||||||
|
<TabsTrigger value="time">Time</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="trends" className="mt-6">
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="grid grid-cols-1 gap-6">
|
||||||
|
{[1, 2].map((i) => (
|
||||||
|
<div key={i} className="h-[350px] animate-pulse rounded-lg border bg-muted/30 p-6" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AnalyticsCharts
|
||||||
|
timeData={timeData}
|
||||||
|
domainData={domainData}
|
||||||
|
habitData={habitData}
|
||||||
|
activeTab="trends"
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="habits" className="mt-6">
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="h-[350px] animate-pulse rounded-lg border bg-muted/30 p-6" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AnalyticsCharts
|
||||||
|
timeData={timeData}
|
||||||
|
domainData={domainData}
|
||||||
|
habitData={habitData}
|
||||||
|
activeTab="habits"
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="time" className="mt-6">
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="h-[350px] animate-pulse rounded-lg border bg-muted/30 p-6" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AnalyticsCharts
|
||||||
|
timeData={timeData}
|
||||||
|
domainData={domainData}
|
||||||
|
habitData={habitData}
|
||||||
|
activeTab="time"
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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: () => (
|
||||||
|
<div className="flex h-[600px] items-center justify-center rounded-lg border bg-muted/30">
|
||||||
|
<div className="animate-pulse text-sm text-muted-foreground">Loading calendar...</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
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<CalendarEvent[]>([]);
|
||||||
|
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<string[]>([]);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<p className="text-muted-foreground">Loading calendar...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Calendar</h1>
|
||||||
|
<p className="mt-1 text-muted-foreground">Your commitments, in time.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[250px_1fr]">
|
||||||
|
{/* Filters sidebar */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Filter className="h-4 w-4" aria-hidden="true" />
|
||||||
|
Filters
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Entity types */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h2 className="text-sm font-semibold">Show</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="tasks"
|
||||||
|
checked={showTasks}
|
||||||
|
onCheckedChange={(checked) => setShowTasks(checked === true)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="tasks" className="flex items-center gap-2">
|
||||||
|
<div className="h-3 w-3 rounded" style={{ backgroundColor: '#3b82f6' }} />
|
||||||
|
Tasks
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="habits"
|
||||||
|
checked={showHabits}
|
||||||
|
onCheckedChange={(checked) => setShowHabits(checked === true)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="habits" className="flex items-center gap-2">
|
||||||
|
<div className="h-3 w-3 rounded" style={{ backgroundColor: '#10b981' }} />
|
||||||
|
Habits
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="projects"
|
||||||
|
checked={showProjects}
|
||||||
|
onCheckedChange={(checked) => setShowProjects(checked === true)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="projects" className="flex items-center gap-2">
|
||||||
|
<div className="h-3 w-3 rounded" style={{ backgroundColor: '#8b5cf6' }} />
|
||||||
|
Projects
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="milestones"
|
||||||
|
checked={showMilestones}
|
||||||
|
onCheckedChange={(checked) => setShowMilestones(checked === true)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="milestones" className="flex items-center gap-2">
|
||||||
|
<div className="h-3 w-3 rounded" style={{ backgroundColor: '#f59e0b' }} />
|
||||||
|
Milestones
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Domains */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h2 className="text-sm font-semibold">Domains</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{['personal', 'work', 'ots'].map((domain) => (
|
||||||
|
<div key={domain} className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={domain}
|
||||||
|
checked={selectedDomains.includes(domain)}
|
||||||
|
onCheckedChange={() => toggleDomain(domain)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={domain}>
|
||||||
|
<Badge variant="outline">{domain}</Badge>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{selectedDomains.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedDomains([])}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="space-y-2 border-t pt-4">
|
||||||
|
<h2 className="text-sm font-semibold">Legend</h2>
|
||||||
|
<div className="space-y-1 text-xs text-muted-foreground">
|
||||||
|
<p>• Tasks show on due date</p>
|
||||||
|
<p>• Projects show on deadline</p>
|
||||||
|
<p>• Milestones show on due date</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Calendar */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="flex h-[600px] items-center justify-center rounded-lg border bg-muted/30">
|
||||||
|
<div className="animate-pulse text-sm text-muted-foreground">Loading calendar...</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<BigCalendar events={filteredEvents} />
|
||||||
|
</Suspense>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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: () => (
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-[200px] animate-pulse rounded-lg border bg-muted/30" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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: () => <WidgetSkeleton />,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const HabitChecklistWidget = dynamic(
|
||||||
|
() =>
|
||||||
|
import('@/components/dashboard/widgets/habit-checklist-widget').then(
|
||||||
|
(m) => m.HabitChecklistWidget
|
||||||
|
),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <WidgetSkeleton />,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const WeeklyStatsWidget = dynamic(
|
||||||
|
() =>
|
||||||
|
import('@/components/dashboard/widgets/weekly-stats-widget').then((m) => m.WeeklyStatsWidget),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <WidgetSkeleton />,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const ProjectProgressWidget = dynamic(
|
||||||
|
() =>
|
||||||
|
import('@/components/dashboard/widgets/project-progress-widget').then(
|
||||||
|
(m) => m.ProjectProgressWidget
|
||||||
|
),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <WidgetSkeleton />,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const HabitStreaksWidget = dynamic(
|
||||||
|
() =>
|
||||||
|
import('@/components/dashboard/widgets/habit-streaks-widget').then((m) => m.HabitStreaksWidget),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <WidgetSkeleton />,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const CalendarMiniWidget = dynamic(
|
||||||
|
() =>
|
||||||
|
import('@/components/dashboard/widgets/calendar-mini-widget').then((m) => m.CalendarMiniWidget),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <WidgetSkeleton />,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const QuickAddWidget = dynamic(
|
||||||
|
() =>
|
||||||
|
import('@/components/dashboard/widgets/quick-add-widget').then((m) => m.QuickAddWidget),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <WidgetSkeleton />,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const RecentActivityWidget = dynamic(
|
||||||
|
() =>
|
||||||
|
import('@/components/dashboard/widgets/recent-activity-widget').then(
|
||||||
|
(m) => m.RecentActivityWidget
|
||||||
|
),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <WidgetSkeleton />,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function WidgetSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="h-full animate-pulse rounded-lg border bg-muted/30 p-4">
|
||||||
|
<div className="mb-3 h-4 w-24 rounded bg-muted/50" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-3 w-full rounded bg-muted/50" />
|
||||||
|
<div className="h-3 w-3/4 rounded bg-muted/50" />
|
||||||
|
<div className="h-3 w-1/2 rounded bg-muted/50" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const widgetComponents: Record<string, React.ComponentType> = {
|
||||||
|
'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 (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||||
|
<p className="mt-1 text-muted-foreground">Your day, at a glance.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ResponsiveGridLayout layout={layout} onLayoutChange={handleLayoutChange}>
|
||||||
|
{widgets.map((widget) => {
|
||||||
|
const WidgetComponent = widgetComponents[widget.id];
|
||||||
|
if (!WidgetComponent) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={widget.id}>
|
||||||
|
<WidgetErrorBoundary widgetName={widget.type}>
|
||||||
|
<div className="h-full rounded-lg border bg-card p-4 shadow-sm">
|
||||||
|
<div className="widget-drag-handle">
|
||||||
|
<Suspense fallback={<WidgetSkeleton />}>
|
||||||
|
<WidgetComponent />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</WidgetErrorBoundary>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ResponsiveGridLayout>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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: () => (
|
||||||
|
<div className="h-[150px] animate-pulse rounded-lg bg-muted/30" />
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Extended habit with server-computed fields */
|
||||||
|
interface HabitWithMeta extends Habit {
|
||||||
|
logged_today: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HabitsPage() {
|
||||||
|
const [habits, setHabits] = useState<HabitWithMeta[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedHabit, setSelectedHabit] = useState<HabitWithMeta | null>(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 <p className="text-muted-foreground">Loading habits...</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Habits</h1>
|
||||||
|
<p className="mt-1 text-muted-foreground">
|
||||||
|
Small actions, visible momentum.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
New habit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary banner */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardContent className="flex items-center justify-between p-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Today's progress</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{completedCount} / {habits.length} habits
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm text-muted-foreground">Completion rate</p>
|
||||||
|
<p className="text-2xl font-bold">{completionRate}%</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Habit cards grid */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{habits.map((habit) => (
|
||||||
|
<HabitCard
|
||||||
|
key={habit.id}
|
||||||
|
habit={habit}
|
||||||
|
onComplete={() => handleComplete(habit)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Heatmap section */}
|
||||||
|
<Card className="mt-8">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Flame className="h-5 w-5 text-orange-500" aria-hidden="true" />
|
||||||
|
Consistency Overview
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="h-[150px] animate-pulse rounded-lg bg-muted/30" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<HabitHeatmap habits={habits} />
|
||||||
|
</Suspense>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Completion dialog */}
|
||||||
|
{selectedHabit && (
|
||||||
|
<HabitCompletionDialog
|
||||||
|
habit={selectedHabit}
|
||||||
|
open={completionDialogOpen}
|
||||||
|
onOpenChange={setCompletionDialogOpen}
|
||||||
|
onSubmit={(data) => logHabitCompletion(selectedHabit.id, data)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<KeyboardShortcutsProvider>
|
||||||
|
<WebVitalsTracker />
|
||||||
|
<a href="#main-content" className="skip-link">
|
||||||
|
Skip to main content
|
||||||
|
</a>
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
<NetworkErrorBanner />
|
||||||
|
<Sidebar />
|
||||||
|
<div className="flex flex-1 flex-col">
|
||||||
|
<TopBar />
|
||||||
|
<main id="main-content" className="flex-1 overflow-auto p-6" tabIndex={-1}>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sr-only" aria-live="polite" aria-atomic="true" id="a11y-announcer" />
|
||||||
|
</KeyboardShortcutsProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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: () => (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<div className="animate-pulse text-sm text-muted-foreground">Loading editor...</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Lazy load react-force-graph-2d (~120KB + three.js)
|
||||||
|
const NoteGraph = dynamic(
|
||||||
|
() => import('@/components/notes/note-graph').then((m) => m.NoteGraph),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<div className="animate-pulse text-sm text-muted-foreground">Loading graph...</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
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<Note[]>([]);
|
||||||
|
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
|
||||||
|
const [backlinks, setBacklinks] = useState<Backlink[]>([]);
|
||||||
|
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<string, unknown>) {
|
||||||
|
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<Note>) {
|
||||||
|
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 <p className="text-muted-foreground">Loading notes...</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Notes</h1>
|
||||||
|
<p className="mt-1 text-muted-foreground">
|
||||||
|
Connect ideas to the work they shape.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<DailyNoteButton onNoteReady={handleDailyNoteReady} />
|
||||||
|
<Button onClick={createNote}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
New note
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[250px_1fr_300px]">
|
||||||
|
{/* Notes list */}
|
||||||
|
<Card className="h-[calc(100vh-200px)]">
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<div className="p-2">
|
||||||
|
{notes.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
No notes yet
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{notes.map((note) => (
|
||||||
|
<button
|
||||||
|
key={note.id}
|
||||||
|
onClick={() => setSelectedNote(note)}
|
||||||
|
aria-label={`Open note: ${note.title}`}
|
||||||
|
aria-current={selectedNote?.id === note.id ? 'true' : undefined}
|
||||||
|
className={`w-full rounded-lg p-3 text-left transition-colors ${
|
||||||
|
selectedNote?.id === note.id
|
||||||
|
? 'bg-accent'
|
||||||
|
: 'hover:bg-accent/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium">
|
||||||
|
{note.title}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||||
|
{new Date(note.updated).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
<Badge variant="outline" className="mt-1 text-xs">
|
||||||
|
{note.domain}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Note editor */}
|
||||||
|
<Card className="h-[calc(100vh-200px)]">
|
||||||
|
{selectedNote ? (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="border-b p-4">
|
||||||
|
<label htmlFor="note-title" className="sr-only">
|
||||||
|
Note title
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="note-title"
|
||||||
|
type="text"
|
||||||
|
value={selectedNote.title}
|
||||||
|
onChange={(e) =>
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto p-4">
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<div className="animate-pulse text-sm text-muted-foreground">
|
||||||
|
Loading editor...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<NoteEditor
|
||||||
|
content={selectedNote.content}
|
||||||
|
onChange={(content) =>
|
||||||
|
setSelectedNote({ ...selectedNote, content })
|
||||||
|
}
|
||||||
|
onBlur={() =>
|
||||||
|
updateNote(selectedNote.id, {
|
||||||
|
content: selectedNote.content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Select a note or create a new one
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Backlinks and graph */}
|
||||||
|
<Card className="h-[calc(100vh-200px)]">
|
||||||
|
<Tabs defaultValue="backlinks" className="h-full">
|
||||||
|
<div className="border-b p-2">
|
||||||
|
<TabsList className="w-full">
|
||||||
|
<TabsTrigger value="backlinks" className="flex-1 gap-2">
|
||||||
|
<Link2 className="h-3 w-3" aria-hidden="true" />
|
||||||
|
Backlinks
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="graph" className="flex-1 gap-2">
|
||||||
|
<GitBranch className="h-3 w-3" aria-hidden="true" />
|
||||||
|
Graph
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TabsContent value="backlinks" className="h-full overflow-auto p-4">
|
||||||
|
{backlinks.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
No backlinks
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{backlinks.map((link) => (
|
||||||
|
<button
|
||||||
|
key={link.id}
|
||||||
|
onClick={() => {
|
||||||
|
const note = notes.find((n) => n.id === link.id);
|
||||||
|
if (note) setSelectedNote(note);
|
||||||
|
}}
|
||||||
|
aria-label={`Open linked note: ${link.title}`}
|
||||||
|
className="w-full rounded-lg border p-3 text-left transition-colors hover:bg-accent"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{link.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="graph" className="h-full p-4">
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<div className="animate-pulse text-sm text-muted-foreground">
|
||||||
|
Loading graph...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<NoteGraph notes={notes} />
|
||||||
|
</Suspense>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Project | null>(null);
|
||||||
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
|
const [milestones, setMilestones] = useState<Milestone[]>([]);
|
||||||
|
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 <p className="text-muted-foreground">Loading project...</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Back button */}
|
||||||
|
<Link href="/projects">
|
||||||
|
<Button variant="ghost" size="sm" className="mb-4">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
|
Back to projects
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Project header */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">{project.name}</h1>
|
||||||
|
{project.description && (
|
||||||
|
<p className="mt-1 text-muted-foreground">{project.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
project.status === 'active'
|
||||||
|
? 'default'
|
||||||
|
: project.status === 'paused'
|
||||||
|
? 'secondary'
|
||||||
|
: 'outline'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{project.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Project stats */}
|
||||||
|
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Progress</p>
|
||||||
|
<p className="text-2xl font-bold">{project.progress}%</p>
|
||||||
|
</div>
|
||||||
|
<CheckCircle2 className="h-8 w-8 text-green-600" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<Progress value={project.progress} className="mt-2 h-2" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Tasks</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{project.completed_count} / {project.task_count}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Circle className="h-8 w-8 text-blue-600" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Due Date</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{project.due_date
|
||||||
|
? new Date(project.due_date).toLocaleDateString()
|
||||||
|
: 'No date'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Calendar className="h-8 w-8 text-orange-600" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<Tabs defaultValue="tasks">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="tasks">Tasks ({tasks.length})</TabsTrigger>
|
||||||
|
<TabsTrigger value="milestones">
|
||||||
|
Milestones ({milestones.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="habits">Habits</TabsTrigger>
|
||||||
|
<TabsTrigger value="notes">Notes</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="tasks" className="mt-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Project Tasks</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{tasks.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-muted-foreground">
|
||||||
|
No tasks yet
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{tasks.map((task) => (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
className="flex items-center gap-3 rounded-lg border p-3"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-11 w-11 shrink-0"
|
||||||
|
onClick={() =>
|
||||||
|
toggleTaskComplete(task.id, task.status)
|
||||||
|
}
|
||||||
|
aria-label={`Mark "${task.title}" as ${task.status === 'done' ? 'incomplete' : 'complete'}`}
|
||||||
|
>
|
||||||
|
{task.status === 'done' ? (
|
||||||
|
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Circle className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p
|
||||||
|
className={`text-sm font-medium ${
|
||||||
|
task.status === 'done'
|
||||||
|
? 'text-muted-foreground line-through'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{task.title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
task.priority === 'urgent'
|
||||||
|
? 'destructive'
|
||||||
|
: task.priority === 'high'
|
||||||
|
? 'default'
|
||||||
|
: 'secondary'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{task.priority}
|
||||||
|
</Badge>
|
||||||
|
{task.due_date && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{new Date(task.due_date).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="milestones" className="mt-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Milestones</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{milestones.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-muted-foreground">
|
||||||
|
No milestones yet
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{milestones.map((milestone, index) => (
|
||||||
|
<div key={milestone.id} className="relative flex gap-4">
|
||||||
|
{/* Timeline line */}
|
||||||
|
{index < milestones.length - 1 && (
|
||||||
|
<div className="absolute left-5 top-12 h-full w-0.5 bg-border" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Milestone marker */}
|
||||||
|
<div className="relative z-10 flex h-10 w-10 shrink-0 items-center justify-center rounded-full border-2 bg-background">
|
||||||
|
<Flag
|
||||||
|
className={`h-5 w-5 ${
|
||||||
|
milestone.status === 'completed'
|
||||||
|
? 'text-green-600'
|
||||||
|
: milestone.status === 'in_progress'
|
||||||
|
? 'text-blue-600'
|
||||||
|
: 'text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Milestone content */}
|
||||||
|
<div className="flex-1 pb-6">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold">
|
||||||
|
{milestone.name}
|
||||||
|
</h2>
|
||||||
|
{milestone.description && (
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{milestone.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
milestone.status === 'completed'
|
||||||
|
? 'default'
|
||||||
|
: milestone.status === 'in_progress'
|
||||||
|
? 'secondary'
|
||||||
|
: 'outline'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{milestone.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{milestone.due_date && (
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
Due:{' '}
|
||||||
|
{new Date(
|
||||||
|
milestone.due_date
|
||||||
|
).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="mb-1 flex items-center justify-between text-xs">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Tasks
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{milestone.completed_tasks} /{' '}
|
||||||
|
{milestone.total_tasks}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={
|
||||||
|
milestone.total_tasks > 0
|
||||||
|
? (milestone.completed_tasks /
|
||||||
|
milestone.total_tasks) *
|
||||||
|
100
|
||||||
|
: 0
|
||||||
|
}
|
||||||
|
className="h-1.5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="habits" className="mt-6">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-8 text-center text-muted-foreground">
|
||||||
|
Habits linked to this project will appear here
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="notes" className="mt-6">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-8 text-center text-muted-foreground">
|
||||||
|
Notes linked to this project will appear here
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Project[]>([]);
|
||||||
|
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 <p className="text-muted-foreground">Loading projects...</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Projects</h1>
|
||||||
|
<p className="mt-1 text-muted-foreground">Every outcome has a home.</p>
|
||||||
|
</div>
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
New project
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active projects */}
|
||||||
|
{activeProjects.length > 0 && (
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold">Active Projects</h2>
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{activeProjects.map((project) => (
|
||||||
|
<ProjectCard key={project.id} project={project} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Paused projects */}
|
||||||
|
{pausedProjects.length > 0 && (
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold">Paused Projects</h2>
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{pausedProjects.map((project) => (
|
||||||
|
<ProjectCard key={project.id} project={project} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Archived projects */}
|
||||||
|
{archivedProjects.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-4 text-lg font-semibold">Archived Projects</h2>
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{archivedProjects.map((project) => (
|
||||||
|
<ProjectCard key={project.id} project={project} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{projects.length === 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<FolderKanban className="mb-4 h-12 w-12 text-muted-foreground" aria-hidden="true" />
|
||||||
|
<p className="text-lg font-semibold">No projects yet</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Create your first project to get started
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectCard({ project }: { project: Project }) {
|
||||||
|
return (
|
||||||
|
<Link href={`/projects/${project.id}`}>
|
||||||
|
<Card className="h-full transition-shadow hover:shadow-md">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<CardTitle className="text-base">{project.name}</CardTitle>
|
||||||
|
{project.description && (
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">
|
||||||
|
{project.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
project.status === 'active'
|
||||||
|
? 'default'
|
||||||
|
: project.status === 'paused'
|
||||||
|
? 'secondary'
|
||||||
|
: 'outline'
|
||||||
|
}
|
||||||
|
className="ml-2 shrink-0"
|
||||||
|
>
|
||||||
|
{project.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{/* Progress */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 flex items-center justify-between text-xs">
|
||||||
|
<span className="text-muted-foreground">Progress</span>
|
||||||
|
<span className="font-semibold">{project.progress}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={project.progress} className="h-2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Task count */}
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Tasks</span>
|
||||||
|
<span className="font-semibold">
|
||||||
|
{project.completed_count} / {project.task_count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Domain and due date */}
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<Badge variant="outline">{project.domain}</Badge>
|
||||||
|
{project.due_date && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Due: {new Date(project.due_date).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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: () => (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<div className="animate-pulse text-sm text-muted-foreground">Loading editor...</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Lazy load report templates
|
||||||
|
const ReportTemplates = dynamic(
|
||||||
|
() => import('@/components/reports/report-templates').then((m) => m.ReportTemplates),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<div className="animate-pulse text-sm text-muted-foreground">Loading templates...</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
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<Report[]>([]);
|
||||||
|
const [selectedReport, setSelectedReport] = useState<Report | null>(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<Report>) {
|
||||||
|
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<Report>) {
|
||||||
|
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 <Calendar className="h-4 w-4" />;
|
||||||
|
case 'monthly':
|
||||||
|
return <Calendar className="h-4 w-4" />;
|
||||||
|
case 'project':
|
||||||
|
return <Target className="h-4 w-4" />;
|
||||||
|
case 'habit':
|
||||||
|
return <TrendingUp className="h-4 w-4" />;
|
||||||
|
case 'custom':
|
||||||
|
return <FileBarChart className="h-4 w-4" />;
|
||||||
|
default:
|
||||||
|
return <FileBarChart className="h-4 w-4" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <p className="text-muted-foreground">Loading reports...</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showTemplates) {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<div className="animate-pulse text-sm text-muted-foreground">Loading templates...</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ReportTemplates
|
||||||
|
onSelect={(template) => {
|
||||||
|
createReport({
|
||||||
|
title: template.name,
|
||||||
|
report_type: template.type,
|
||||||
|
content: template.content,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onCancel={() => setShowTemplates(false)}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Reports</h1>
|
||||||
|
<p className="mt-1 text-muted-foreground">Step back and see what changed.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setShowTemplates(true)}>
|
||||||
|
From template
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => createReport()}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
New report
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[300px_1fr]">
|
||||||
|
{/* Reports list */}
|
||||||
|
<Card className="h-[calc(100vh-200px)] overflow-auto">
|
||||||
|
<div className="p-2">
|
||||||
|
{reports.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
No reports yet
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{reports.map((report) => (
|
||||||
|
<button
|
||||||
|
key={report.id}
|
||||||
|
onClick={() => setSelectedReport(report)}
|
||||||
|
aria-label={`Open report: ${report.title}`}
|
||||||
|
aria-current={selectedReport?.id === report.id ? 'true' : undefined}
|
||||||
|
className={`w-full rounded-lg p-3 text-left transition-colors ${
|
||||||
|
selectedReport?.id === report.id
|
||||||
|
? 'bg-accent'
|
||||||
|
: 'hover:bg-accent/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="mt-0.5 text-muted-foreground" aria-hidden="true">
|
||||||
|
{getReportTypeIcon(report.report_type)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium">{report.title}</p>
|
||||||
|
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||||
|
{new Date(report.updated).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
<div className="mt-1 flex gap-1">
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{report.report_type}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{report.domain}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Report editor */}
|
||||||
|
<Card className="h-[calc(100vh-200px)]">
|
||||||
|
{selectedReport ? (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="border-b p-4">
|
||||||
|
<label htmlFor="report-title" className="sr-only">
|
||||||
|
Report title
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="report-title"
|
||||||
|
type="text"
|
||||||
|
value={selectedReport.title}
|
||||||
|
onChange={(e) =>
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<Badge variant="outline">{selectedReport.report_type}</Badge>
|
||||||
|
<Badge variant="outline">{selectedReport.domain}</Badge>
|
||||||
|
{selectedReport.date_range_start && selectedReport.date_range_end && (
|
||||||
|
<Badge variant="outline" className="gap-1">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{new Date(selectedReport.date_range_start).toLocaleDateString()} -{' '}
|
||||||
|
{new Date(selectedReport.date_range_end).toLocaleDateString()}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto p-4">
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<div className="animate-pulse text-sm text-muted-foreground">
|
||||||
|
Loading editor...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ReportEditor
|
||||||
|
content={selectedReport.content}
|
||||||
|
onChange={(content) =>
|
||||||
|
setSelectedReport({ ...selectedReport, content })
|
||||||
|
}
|
||||||
|
onBlur={() =>
|
||||||
|
updateReport(selectedReport.id, { content: selectedReport.content })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<p className="text-muted-foreground">Select a report or create a new one</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, unknown>;
|
||||||
|
created: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ErrorLogPage() {
|
||||||
|
const [errors, setErrors] = useState<ErrorLog[]>([]);
|
||||||
|
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 (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Error Log</CardTitle>
|
||||||
|
<CardDescription>Loading...</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Error Log</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Recent errors from the application (auto-purged after 30 days)
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
{errors.length > 0 && (
|
||||||
|
<Button variant="outline" size="sm" onClick={clearErrors} aria-label="Clear all error logs">
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
|
Clear all
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{errors.length === 0 ? (
|
||||||
|
<p className="text-center text-muted-foreground py-8">
|
||||||
|
No errors logged
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{errors.map((error) => (
|
||||||
|
<div
|
||||||
|
key={error.id}
|
||||||
|
className="rounded-lg border border-border bg-card p-4 space-y-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-destructive" aria-hidden="true" />
|
||||||
|
<span className="text-sm font-medium">{error.level}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{error.source}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{new Date(error.created).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm">{error.message}</p>
|
||||||
|
{error.metadata &&
|
||||||
|
Object.keys(error.metadata).length > 0 && (
|
||||||
|
<details className="text-xs">
|
||||||
|
<summary className="cursor-pointer text-muted-foreground hover:text-foreground" role="button">
|
||||||
|
Details
|
||||||
|
</summary>
|
||||||
|
<pre className="mt-2 rounded bg-muted p-2 overflow-x-auto">
|
||||||
|
{JSON.stringify(error.metadata, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold">Settings</h1>
|
||||||
|
<p className="mt-1 text-muted-foreground">Tune Project E to fit your work.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="appearance" orientation="vertical" className="flex gap-6">
|
||||||
|
<TabsList className="flex w-[200px] flex-col gap-1 bg-transparent h-auto">
|
||||||
|
<TabsTrigger value="appearance" className="justify-start gap-2">
|
||||||
|
<Palette className="h-4 w-4" aria-hidden="true" />
|
||||||
|
Appearance
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="domains" className="justify-start gap-2">
|
||||||
|
<Globe className="h-4 w-4" aria-hidden="true" />
|
||||||
|
Domains
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="shortcuts" className="justify-start gap-2">
|
||||||
|
<Keyboard className="h-4 w-4" aria-hidden="true" />
|
||||||
|
Keyboard Shortcuts
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="agents" className="justify-start gap-2">
|
||||||
|
<Bot className="h-4 w-4" aria-hidden="true" />
|
||||||
|
Agents & Permissions
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="webhooks" className="justify-start gap-2">
|
||||||
|
<Webhook className="h-4 w-4" aria-hidden="true" />
|
||||||
|
Webhooks
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="import-export" className="justify-start gap-2">
|
||||||
|
<Download className="h-4 w-4" aria-hidden="true" />
|
||||||
|
Import & Export
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="error-log" className="justify-start gap-2">
|
||||||
|
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
|
||||||
|
Error Log
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<TabsContent value="appearance">
|
||||||
|
<SettingsAppearance />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="domains">
|
||||||
|
<SettingsDomains />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="shortcuts">
|
||||||
|
<SettingsShortcuts />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="agents">
|
||||||
|
<SettingsAgents />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="webhooks">
|
||||||
|
<SettingsWebhooks />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="import-export">
|
||||||
|
<SettingsImportExport />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="error-log">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Error Log</CardTitle>
|
||||||
|
<CardDescription>View recent application errors</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
See the{' '}
|
||||||
|
<a href="/settings/error-log" className="text-primary underline">
|
||||||
|
detailed error log
|
||||||
|
</a>{' '}
|
||||||
|
for more information.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Tasks</h1>
|
||||||
|
<p className="mt-1 text-muted-foreground">
|
||||||
|
Move work forward without losing the thread.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Tabs
|
||||||
|
value={view}
|
||||||
|
onValueChange={(v) => setView(v as 'kanban' | 'list')}
|
||||||
|
>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="kanban" className="gap-2">
|
||||||
|
<LayoutGrid className="h-4 w-4" aria-hidden="true" />
|
||||||
|
Board
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="list" className="gap-2">
|
||||||
|
<List className="h-4 w-4" aria-hidden="true" />
|
||||||
|
List
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{view === 'kanban' ? <TasksKanbanView /> : <TasksListView />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<RouteContext>(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<RouteContext>(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<RouteContext>(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 });
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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<string, unknown>) => 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<string, unknown>) => sum + ((e.duration_minutes as number) || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Active streaks
|
||||||
|
const activeStreaks = habits.filter(
|
||||||
|
(h: Record<string, unknown>) => ((h.current_streak as number) || 0) > 0,
|
||||||
|
);
|
||||||
|
const bestStreak = Math.max(
|
||||||
|
...habits.map((h: Record<string, unknown>) => (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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<RouteContext>(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<RouteContext>(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<RouteContext>(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 });
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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<RouteContext>(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<RouteContext>(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<RouteContext>(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 });
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -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<string, unknown> = {
|
||||||
|
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, ' '),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<RouteContext>(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<RouteContext>(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;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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<RouteContext>(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<RouteContext>(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<RouteContext>(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 });
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, WebStandardStreamableHTTPServerTransport>();
|
||||||
|
|
||||||
|
async function authenticateRequest(request: NextRequest): Promise<boolean> {
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
@@ -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<RouteContext>(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<RouteContext>(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<RouteContext>(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 });
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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<RouteContext>(
|
||||||
|
async (request: NextRequest, _user, context) => {
|
||||||
|
const { id } = await context!.params;
|
||||||
|
const backlinks = await getBacklinks(id);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
items: backlinks,
|
||||||
|
totalItems: backlinks.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -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<RouteContext>(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<RouteContext>(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<RouteContext>(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 });
|
||||||
|
});
|
||||||
@@ -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, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build an <ul> of items, or an empty-state <p> if the list is empty. */
|
||||||
|
function list(items: string[], emptyMsg: string): string {
|
||||||
|
if (items.length === 0) {
|
||||||
|
return `<p><em>${esc(emptyMsg)}</em></p>`;
|
||||||
|
}
|
||||||
|
return `<ul>${items.map((t) => `<li>${t}</li>`).join('')}</ul>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate the full HTML body for a daily note. */
|
||||||
|
function buildDailyNoteHtml(ctx: {
|
||||||
|
completedTasks: string[];
|
||||||
|
habitLogs: string[];
|
||||||
|
timeEntries: string[];
|
||||||
|
overdueTasks: string[];
|
||||||
|
}): string {
|
||||||
|
return [
|
||||||
|
`<h2>Tasks Completed</h2>`,
|
||||||
|
list(ctx.completedTasks, 'No tasks completed today.'),
|
||||||
|
`<h2>Habits Logged</h2>`,
|
||||||
|
list(ctx.habitLogs, 'No habits logged today.'),
|
||||||
|
`<h2>Time Tracked</h2>`,
|
||||||
|
list(ctx.timeEntries, 'No time tracked today.'),
|
||||||
|
`<h2>Overdue Items</h2>`,
|
||||||
|
list(ctx.overdueTasks, 'Nothing overdue.'),
|
||||||
|
`<h2>Notes</h2>`,
|
||||||
|
`<p></p>`,
|
||||||
|
`<h2>Reflections</h2>`,
|
||||||
|
`<p></p>`,
|
||||||
|
`<h2>Gratitude</h2>`,
|
||||||
|
`<p></p>`,
|
||||||
|
].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<string, string>();
|
||||||
|
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<string, string>();
|
||||||
|
|
||||||
|
// Fetch task names in parallel for time entries and overdue tasks
|
||||||
|
const allTaskIds = new Set<string>();
|
||||||
|
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 `<strong>${dur}</strong> 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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<RouteContext>(async (_request: NextRequest, _user, context) => {
|
||||||
|
const { id } = await context!.params;
|
||||||
|
const progress = await computeProjectProgress(id);
|
||||||
|
return NextResponse.json({ progress });
|
||||||
|
});
|
||||||
@@ -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<RouteContext>(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<RouteContext>(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<RouteContext>(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 });
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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<void>> = [];
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<RouteContext>(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<RouteContext>(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<RouteContext>(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 });
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -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<RouteContext>(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<RouteContext>(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<RouteContext>(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 });
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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<RouteContext>(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<RouteContext>(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<RouteContext>(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 });
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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<string, number> = {};
|
||||||
|
const byProject: Record<string, number> = {};
|
||||||
|
const byTag: Record<string, number> = {};
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<RouteContext>(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' });
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<RouteContext>(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<RouteContext>(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<RouteContext>(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 });
|
||||||
|
});
|
||||||
@@ -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<RouteContext>(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<string, string> = {
|
||||||
|
'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<string, unknown>,
|
||||||
|
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<string, unknown>,
|
||||||
|
success: false,
|
||||||
|
response_status: 0,
|
||||||
|
response_body: errorMessage,
|
||||||
|
attempts: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
status: 0,
|
||||||
|
response: errorMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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 (
|
||||||
|
<div className="flex min-h-[50vh] items-center justify-center p-6">
|
||||||
|
<Card className="max-w-md w-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Something went wrong</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
An unexpected error occurred. Please try again or contact support
|
||||||
|
if the problem persists.
|
||||||
|
</p>
|
||||||
|
{error.digest && (
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground font-mono">
|
||||||
|
Error ID: {error.digest}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex gap-2">
|
||||||
|
<Button onClick={reset}>Try again</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => (window.location.href = '/')}
|
||||||
|
>
|
||||||
|
Go home
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<html lang="en" suppressHydrationWarning>
|
||||||
|
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||||
|
<ThemeProvider>{children}</ThemeProvider>
|
||||||
|
<Toaster position="top-right" />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex min-h-[50vh] items-center justify-center p-6">
|
||||||
|
<Card className="max-w-md w-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Page not found</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
The page you're looking for doesn't exist or has been
|
||||||
|
moved.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/">Go home</Link>
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
redirect('/dashboard');
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="grid grid-cols-1 gap-6">
|
||||||
|
{/* Productivity trend */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-5 w-5" aria-hidden="true" />
|
||||||
|
Productivity Trend
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<AreaChart data={timeData}>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
stroke="hsl(var(--border))"
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
stroke="hsl(var(--muted-foreground))"
|
||||||
|
fontSize={12}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
stroke="hsl(var(--muted-foreground))"
|
||||||
|
fontSize={12}
|
||||||
|
/>
|
||||||
|
<Tooltip contentStyle={chartTooltipStyle} />
|
||||||
|
<Legend />
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="tasks"
|
||||||
|
stackId="1"
|
||||||
|
stroke="#3b82f6"
|
||||||
|
fill="#3b82f6"
|
||||||
|
fillOpacity={0.6}
|
||||||
|
name="Tasks Completed"
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="habits"
|
||||||
|
stackId="1"
|
||||||
|
stroke="#10b981"
|
||||||
|
fill="#10b981"
|
||||||
|
fillOpacity={0.6}
|
||||||
|
name="Habits Logged"
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Time tracked trend */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Clock className="h-5 w-5" aria-hidden="true" />
|
||||||
|
Time Tracked
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<LineChart data={timeData}>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
stroke="hsl(var(--border))"
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
stroke="hsl(var(--muted-foreground))"
|
||||||
|
fontSize={12}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
stroke="hsl(var(--muted-foreground))"
|
||||||
|
fontSize={12}
|
||||||
|
label={{
|
||||||
|
value: 'Minutes',
|
||||||
|
angle: -90,
|
||||||
|
position: 'insideLeft',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tooltip contentStyle={chartTooltipStyle} />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="time"
|
||||||
|
stroke="#8b5cf6"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ fill: '#8b5cf6', r: 3 }}
|
||||||
|
name="Time (minutes)"
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeTab === 'habits') {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-6">
|
||||||
|
{/* Habit streaks */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Flame className="h-5 w-5" aria-hidden="true" />
|
||||||
|
Habit Streaks
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{habitData.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-muted-foreground">
|
||||||
|
No habits tracked
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<BarChart data={habitData} layout="vertical">
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
stroke="hsl(var(--border))"
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
stroke="hsl(var(--muted-foreground))"
|
||||||
|
fontSize={12}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
dataKey="name"
|
||||||
|
type="category"
|
||||||
|
stroke="hsl(var(--muted-foreground))"
|
||||||
|
fontSize={12}
|
||||||
|
width={120}
|
||||||
|
/>
|
||||||
|
<Tooltip contentStyle={chartTooltipStyle} />
|
||||||
|
<Bar
|
||||||
|
dataKey="streak"
|
||||||
|
fill="#f59e0b"
|
||||||
|
radius={[0, 4, 4, 0]}
|
||||||
|
name="Current Streak (days)"
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Habit scores */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Habit Scores</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{habitData.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-muted-foreground">
|
||||||
|
No habits tracked
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{habitData.map((habit) => (
|
||||||
|
<div key={habit.name}>
|
||||||
|
<div className="mb-1 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{habit.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{habit.score}/100
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary transition-all"
|
||||||
|
style={{ width: `${habit.score}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeTab === 'time') {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-6">
|
||||||
|
{/* Time by domain */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<PieChartIcon className="h-5 w-5" aria-hidden="true" />
|
||||||
|
Time by Domain
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{domainData.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-muted-foreground">
|
||||||
|
No time tracked
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-8">
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={domainData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
labelLine={false}
|
||||||
|
label={({ name, percent }) =>
|
||||||
|
`${name}: ${((percent || 0) * 100).toFixed(0)}%`
|
||||||
|
}
|
||||||
|
outerRadius={100}
|
||||||
|
fill="#8884d8"
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{domainData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip contentStyle={chartTooltipStyle} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Daily breakdown */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<BarChart3 className="h-5 w-5" aria-hidden="true" />
|
||||||
|
Daily Activity
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<BarChart data={timeData.slice(-7)}>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
stroke="hsl(var(--border))"
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
stroke="hsl(var(--muted-foreground))"
|
||||||
|
fontSize={12}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
stroke="hsl(var(--muted-foreground))"
|
||||||
|
fontSize={12}
|
||||||
|
/>
|
||||||
|
<Tooltip contentStyle={chartTooltipStyle} />
|
||||||
|
<Legend />
|
||||||
|
<Bar
|
||||||
|
dataKey="tasks"
|
||||||
|
fill="#3b82f6"
|
||||||
|
name="Tasks"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="habits"
|
||||||
|
fill="#10b981"
|
||||||
|
name="Habits"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Calendar
|
||||||
|
localizer={localizer}
|
||||||
|
events={events}
|
||||||
|
startAccessor="start"
|
||||||
|
endAccessor="end"
|
||||||
|
style={{ height: 600 }}
|
||||||
|
eventPropGetter={eventStyleGetter}
|
||||||
|
onSelectEvent={handleSelectEvent}
|
||||||
|
views={['month', 'week', 'day']}
|
||||||
|
defaultView="month"
|
||||||
|
popup
|
||||||
|
toolbar
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Array<{
|
||||||
|
type: string;
|
||||||
|
items: Array<{ id: string; title: string }>;
|
||||||
|
}>>([]);
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<CommandDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
label="Command palette"
|
||||||
|
className={deepSearch ? 'max-w-2xl' : 'max-w-lg'}
|
||||||
|
>
|
||||||
|
<CommandInput
|
||||||
|
placeholder={deepSearch ? 'Search everything...' : 'Type a command or search...'}
|
||||||
|
onValueChange={handleSearch}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No results found.</CommandEmpty>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
{!deepSearch && (
|
||||||
|
<CommandGroup heading="Jump to">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<CommandItem
|
||||||
|
key={item.href}
|
||||||
|
onSelect={() => runCommand(() => router.push(item.href))}
|
||||||
|
>
|
||||||
|
<item.icon className="mr-2 h-4 w-4" />
|
||||||
|
{item.label}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<CommandGroup heading="Quick actions">
|
||||||
|
{quickActions.map((action) => (
|
||||||
|
<CommandItem
|
||||||
|
key={action.label}
|
||||||
|
onSelect={() => runCommand(action.action)}
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
{action.label}
|
||||||
|
{action.shortcut && (
|
||||||
|
<kbd className="ml-auto pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
|
||||||
|
{action.shortcut}
|
||||||
|
</kbd>
|
||||||
|
)}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
|
||||||
|
{/* Search Results (deep search mode) */}
|
||||||
|
{deepSearch && searchResults.length > 0 && (
|
||||||
|
<>
|
||||||
|
<CommandSeparator />
|
||||||
|
{searchResults.map((group) => (
|
||||||
|
<CommandGroup key={group.type} heading={group.type}>
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<CommandItem
|
||||||
|
key={item.id}
|
||||||
|
onSelect={() => {
|
||||||
|
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}`));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Search className="mr-2 h-4 w-4" />
|
||||||
|
{item.title}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer hint */}
|
||||||
|
<div className="flex items-center justify-between border-t px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
<kbd className="rounded border bg-muted px-1">↑↓</kbd> navigate
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<kbd className="rounded border bg-muted px-1">↵</kbd> select
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<kbd className="rounded border bg-muted px-1">esc</kbd> close
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CommandList>
|
||||||
|
</CommandDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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: <P extends React.ComponentType<React.ComponentProps<P>>>(
|
||||||
|
component: P
|
||||||
|
) => React.ComponentType<React.ComponentProps<P> & { measureBeforeMount?: boolean }>;
|
||||||
|
}
|
||||||
|
).WidthProvider;
|
||||||
|
|
||||||
|
const Responsive = (
|
||||||
|
ReactGridLayout as unknown as {
|
||||||
|
Responsive: React.ComponentType<ReactGridLayout.ResponsiveProps>;
|
||||||
|
}
|
||||||
|
).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 (
|
||||||
|
<ResponsiveGridLayout
|
||||||
|
className="layout"
|
||||||
|
layouts={{ lg: layout }}
|
||||||
|
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
||||||
|
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
|
||||||
|
rowHeight={80}
|
||||||
|
onLayoutChange={onLayoutChange}
|
||||||
|
draggableHandle=".widget-drag-handle"
|
||||||
|
compactType="vertical"
|
||||||
|
isResizable
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ResponsiveGridLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Card className="h-full border-0 shadow-none">
|
||||||
|
<CardHeader className="p-0 pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Calendar className="h-4 w-4" aria-hidden="true" />
|
||||||
|
{today.toLocaleString('default', { month: 'long' })}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="grid grid-cols-7 gap-1 text-center text-xs">
|
||||||
|
{['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((day, i) => (
|
||||||
|
<div key={i} className="font-semibold text-muted-foreground">
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{Array.from({ length: firstDay }).map((_, i) => (
|
||||||
|
<div key={`empty-${i}`} />
|
||||||
|
))}
|
||||||
|
{Array.from({ length: daysInMonth }).map((_, i) => {
|
||||||
|
const day = i + 1;
|
||||||
|
const isToday = day === today.getDate();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={day}
|
||||||
|
className={`rounded p-1 ${
|
||||||
|
isToday
|
||||||
|
? 'bg-primary text-primary-foreground font-semibold'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Habit[]>([]);
|
||||||
|
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 (
|
||||||
|
<Card className="h-full border-0 shadow-none">
|
||||||
|
<CardHeader className="p-0 pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Flame className="h-4 w-4 text-orange-500" aria-hidden="true" />
|
||||||
|
Habits
|
||||||
|
</CardTitle>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{completedCount}/{habits.length} done
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||||
|
) : habits.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No habits tracked</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{habits.slice(0, 5).map((habit) => (
|
||||||
|
<div key={habit.id} className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={habit.id}
|
||||||
|
checked={habit.logged_today}
|
||||||
|
onCheckedChange={() => toggleHabit(habit.id)}
|
||||||
|
aria-label={`Mark "${habit.name}" as ${habit.logged_today ? 'incomplete' : 'complete'}`}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={habit.id}
|
||||||
|
className="flex-1 text-sm cursor-pointer"
|
||||||
|
>
|
||||||
|
{habit.name}
|
||||||
|
</label>
|
||||||
|
{habit.current_streak > 0 && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
🔥 {habit.current_streak}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Streak[]>([]);
|
||||||
|
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 (
|
||||||
|
<Card className="h-full border-0 shadow-none">
|
||||||
|
<CardHeader className="p-0 pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Flame className="h-4 w-4 text-orange-500" aria-hidden="true" />
|
||||||
|
Top Streaks
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||||
|
) : streaks.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No active streaks</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{streaks.slice(0, 5).map((streak, i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between">
|
||||||
|
<span className="text-sm">{streak.habit.name}</span>
|
||||||
|
<span className="text-sm font-semibold">
|
||||||
|
🔥 {streak.streak_current}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Project[]>([]);
|
||||||
|
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 (
|
||||||
|
<Card className="h-full border-0 shadow-none">
|
||||||
|
<CardHeader className="p-0 pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<FolderKanban className="h-4 w-4" aria-hidden="true" />
|
||||||
|
Active Projects
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||||
|
) : projects.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No active projects</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<div key={project.id}>
|
||||||
|
<div className="mb-1 flex items-center justify-between">
|
||||||
|
<span className="text-sm">{project.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{project.progress}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={project.progress} className="h-2" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Card className="h-full border-0 shadow-none">
|
||||||
|
<CardHeader className="p-0 pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||||
|
Quick Add
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="justify-start"
|
||||||
|
onClick={handleQuickAdd}
|
||||||
|
>
|
||||||
|
New task
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="justify-start"
|
||||||
|
onClick={handleQuickAdd}
|
||||||
|
>
|
||||||
|
New habit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="justify-start"
|
||||||
|
onClick={handleQuickAdd}
|
||||||
|
>
|
||||||
|
New note
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Activity } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
|
export function RecentActivityWidget() {
|
||||||
|
return (
|
||||||
|
<Card className="h-full border-0 shadow-none">
|
||||||
|
<CardHeader className="p-0 pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Activity className="h-4 w-4" aria-hidden="true" />
|
||||||
|
Recent Activity
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Activity feed coming soon
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Task[]>([]);
|
||||||
|
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 (
|
||||||
|
<Card className="h-full border-0 shadow-none">
|
||||||
|
<CardHeader className="p-0 pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<ListTodo className="h-4 w-4" aria-hidden="true" />
|
||||||
|
Today's Tasks
|
||||||
|
</CardTitle>
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-xs">
|
||||||
|
View all
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||||
|
) : tasks.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No tasks for today</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{tasks.map((task) => (
|
||||||
|
<div key={task.id} className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-11 w-11 shrink-0"
|
||||||
|
onClick={() => toggleTask(task.id, task.status)}
|
||||||
|
aria-label={`Mark "${task.title}" as ${task.status === 'done' ? 'incomplete' : 'complete'}`}
|
||||||
|
>
|
||||||
|
{task.status === 'done' ? (
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Circle className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<span
|
||||||
|
className={`flex-1 text-sm ${
|
||||||
|
task.status === 'done'
|
||||||
|
? 'line-through text-muted-foreground'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{task.title}
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{task.domain}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<WeeklyStats>({
|
||||||
|
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 (
|
||||||
|
<Card className="h-full border-0 shadow-none">
|
||||||
|
<CardHeader className="p-0 pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<BarChart3 className="h-4 w-4" aria-hidden="true" />
|
||||||
|
This Week
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Task completion
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-sm font-semibold">
|
||||||
|
{stats.taskCompletionRate}%
|
||||||
|
</span>
|
||||||
|
<TrendingUp className="h-3 w-3 text-green-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Habit consistency
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold">
|
||||||
|
{stats.habitConsistency}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Time tracked
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold">
|
||||||
|
{Math.round(stats.totalTimeMinutes / 60)}h
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Card className="relative overflow-hidden">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<CardTitle className="text-base">{habit.name}</CardTitle>
|
||||||
|
{habit.description && (
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">
|
||||||
|
{habit.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="ml-2 shrink-0">
|
||||||
|
{habit.domain}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Streak info */}
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Flame className="h-4 w-4 text-orange-500" aria-hidden="true" />
|
||||||
|
<span className="font-semibold">{habit.current_streak}</span>
|
||||||
|
<span className="text-muted-foreground">day streak</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Best: {habit.best_streak}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Score */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 flex items-center justify-between text-xs">
|
||||||
|
<span className="text-muted-foreground">Score</span>
|
||||||
|
<span className="font-semibold">{habit.score}/100</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={habit.score} className="h-2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Frequency badge */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{habit.frequency}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{habit.completion_mode}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Complete button */}
|
||||||
|
<Button
|
||||||
|
onClick={onComplete}
|
||||||
|
variant={habit.logged_today ? 'outline' : 'default'}
|
||||||
|
className="w-full"
|
||||||
|
disabled={habit.logged_today}
|
||||||
|
>
|
||||||
|
{habit.logged_today ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="mr-2 h-4 w-4 text-green-600" />
|
||||||
|
Completed today
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Circle className="mr-2 h-4 w-4" />
|
||||||
|
Mark complete
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<number | undefined>();
|
||||||
|
const [quantity, setQuantity] = useState<number | undefined>();
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
onSubmit({
|
||||||
|
mood,
|
||||||
|
value: quantity,
|
||||||
|
notes: notes || undefined,
|
||||||
|
});
|
||||||
|
setMood(undefined);
|
||||||
|
setQuantity(undefined);
|
||||||
|
setNotes('');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Log {habit.name}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
How did it go? (optional — you can skip and just log completion)
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
{/* Mood picker */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Mood</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{moods.map((m) => (
|
||||||
|
<Button
|
||||||
|
key={m.value}
|
||||||
|
variant={mood === m.value ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setMood(m.value)}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{m.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quantity */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="quantity">Quantity (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="quantity"
|
||||||
|
type="number"
|
||||||
|
placeholder="e.g., 30 minutes, 10 pages"
|
||||||
|
value={quantity ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setQuantity(e.target.value ? Number(e.target.value) : undefined)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="notes">Notes (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="notes"
|
||||||
|
placeholder="Any thoughts or reflections..."
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={handleSubmit} className="flex-1">
|
||||||
|
Log completion
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onSubmit({})}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Skip
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import CalendarHeatmap from 'react-calendar-heatmap';
|
||||||
|
import type { Habit } from '@project-e/shared';
|
||||||
|
|
||||||
|
interface HeatmapValue {
|
||||||
|
date: Date | string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HabitHeatmapProps {
|
||||||
|
habits: Habit[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HabitHeatmap({ habits }: HabitHeatmapProps) {
|
||||||
|
const [values, setValues] = useState<HeatmapValue[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchHeatmapData();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [habits.length]);
|
||||||
|
|
||||||
|
async function fetchHeatmapData() {
|
||||||
|
try {
|
||||||
|
const oneYearAgo = new Date();
|
||||||
|
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/habit-logs?start=${oneYearAgo.toISOString()}`
|
||||||
|
);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const logs: Array<{ logged_at: string }> = data.items || [];
|
||||||
|
|
||||||
|
// Group by date
|
||||||
|
const byDate: Record<string, number> = {};
|
||||||
|
logs.forEach((log) => {
|
||||||
|
const date = new Date(log.logged_at).toISOString().split('T')[0];
|
||||||
|
byDate[date] = (byDate[date] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const heatmapValues: HeatmapValue[] = Object.entries(byDate).map(
|
||||||
|
([date, count]) => ({
|
||||||
|
date,
|
||||||
|
count,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
setValues(heatmapValues);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch heatmap data:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <p className="text-sm text-muted-foreground">Loading...</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const oneYearAgo = new Date();
|
||||||
|
oneYearAgo.setFullYear(today.getFullYear() - 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<CalendarHeatmap
|
||||||
|
startDate={oneYearAgo}
|
||||||
|
endDate={today}
|
||||||
|
values={values}
|
||||||
|
classForValue={(value) => {
|
||||||
|
if (!value || value.count === 0) return 'heatmap-empty';
|
||||||
|
if (value.count <= 1) return 'heatmap-scale-1';
|
||||||
|
if (value.count <= 2) return 'heatmap-scale-2';
|
||||||
|
if (value.count <= 3) return 'heatmap-scale-3';
|
||||||
|
return 'heatmap-scale-4';
|
||||||
|
}}
|
||||||
|
tooltipDataAttrs={(value) => {
|
||||||
|
if (!value || value.count === 0) return null;
|
||||||
|
const date = new Date(value.date).toLocaleDateString();
|
||||||
|
return {
|
||||||
|
'data-tip': `${date}: ${value.count} habit${value.count === 1 ? '' : 's'}`,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
showWeekdayLabels
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts';
|
||||||
|
import { ShortcutsHelp } from '@/components/shortcuts-help';
|
||||||
|
|
||||||
|
export function KeyboardShortcutsProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
useKeyboardShortcuts();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{children}
|
||||||
|
<ShortcutsHelp />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { AlertCircle, RefreshCw } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
export function NetworkErrorBanner() {
|
||||||
|
const [isOffline, setIsOffline] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOffline = () => setIsOffline(true);
|
||||||
|
const handleOnline = () => setIsOffline(false);
|
||||||
|
|
||||||
|
window.addEventListener('offline', handleOffline);
|
||||||
|
window.addEventListener('online', handleOnline);
|
||||||
|
|
||||||
|
setIsOffline(!navigator.onLine);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('offline', handleOffline);
|
||||||
|
window.removeEventListener('online', handleOnline);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!isOffline) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed top-0 left-0 right-0 z-50 flex items-center justify-center gap-3 bg-destructive px-4 py-2 text-destructive-foreground shadow-lg"
|
||||||
|
role="alert"
|
||||||
|
aria-live="assertive"
|
||||||
|
>
|
||||||
|
<AlertCircle className="h-4 w-4" aria-hidden="true" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
You're offline. Some features may not work.
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 gap-1 px-2 text-xs"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3 w-3" aria-hidden="true" />
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { CalendarDays, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { format, addDays, subDays } from 'date-fns';
|
||||||
|
|
||||||
|
interface DailyNoteButtonProps {
|
||||||
|
/** Called after the daily note is created/retrieved, with the raw PocketBase record. */
|
||||||
|
onNoteReady: (note: Record<string, unknown>) => void;
|
||||||
|
/** Optional: currently selected date (controls the displayed date). */
|
||||||
|
selectedDate?: Date;
|
||||||
|
/** Called when the user navigates to a different date. */
|
||||||
|
onDateChange?: (date: Date) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Button row that creates / navigates daily notes.
|
||||||
|
*
|
||||||
|
* Layout: ◀ [CalendarDays · 2026-07-15] ▶
|
||||||
|
*
|
||||||
|
* Clicking the centre button POSTs to /api/notes/daily and opens the note.
|
||||||
|
* The arrow buttons shift the date by one day without fetching.
|
||||||
|
*/
|
||||||
|
export function DailyNoteButton({
|
||||||
|
onNoteReady,
|
||||||
|
selectedDate,
|
||||||
|
onDateChange,
|
||||||
|
}: DailyNoteButtonProps) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [currentDate, setCurrentDate] = useState<Date>(
|
||||||
|
selectedDate ?? new Date()
|
||||||
|
);
|
||||||
|
|
||||||
|
const dateStr = format(currentDate, 'yyyy-MM-dd');
|
||||||
|
const displayDate = format(currentDate, 'MMM d, yyyy');
|
||||||
|
|
||||||
|
const navigate = useCallback(
|
||||||
|
(delta: number) => {
|
||||||
|
const next = delta > 0 ? addDays(currentDate, 1) : subDays(currentDate, 1);
|
||||||
|
setCurrentDate(next);
|
||||||
|
onDateChange?.(next);
|
||||||
|
},
|
||||||
|
[currentDate, onDateChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleCreateDailyNote() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/notes/daily', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ date: dateStr }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error('Failed to create daily note', await res.text());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const note = await res.json();
|
||||||
|
onNoteReady(note);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create daily note:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
aria-label="Previous day"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
onClick={handleCreateDailyNote}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<CalendarDays className="h-4 w-4" />
|
||||||
|
{loading ? 'Creating…' : `Daily Note — ${displayDate}`}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9"
|
||||||
|
onClick={() => navigate(1)}
|
||||||
|
aria-label="Next day"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEditor, EditorContent } from '@tiptap/react';
|
||||||
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
|
import Link from '@tiptap/extension-link';
|
||||||
|
import Placeholder from '@tiptap/extension-placeholder';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Bold,
|
||||||
|
Italic,
|
||||||
|
Strikethrough,
|
||||||
|
Code,
|
||||||
|
List,
|
||||||
|
ListOrdered,
|
||||||
|
Quote,
|
||||||
|
Undo,
|
||||||
|
Redo,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface NoteEditorProps {
|
||||||
|
content: string;
|
||||||
|
onChange: (content: string) => void;
|
||||||
|
onBlur?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NoteEditor({ content, onChange, onBlur }: NoteEditorProps) {
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: [
|
||||||
|
StarterKit,
|
||||||
|
Link.configure({
|
||||||
|
openOnClick: false,
|
||||||
|
}),
|
||||||
|
Placeholder.configure({
|
||||||
|
placeholder: 'Start writing... Use [[Note Title]] to link to other notes',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
content,
|
||||||
|
onUpdate: ({ editor: e }) => {
|
||||||
|
onChange(e.getHTML());
|
||||||
|
},
|
||||||
|
onBlur: () => {
|
||||||
|
onBlur?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editor && content !== editor.getHTML()) {
|
||||||
|
editor.commands.setContent(content);
|
||||||
|
}
|
||||||
|
}, [content, editor]);
|
||||||
|
|
||||||
|
if (!editor) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="mb-4 flex flex-wrap gap-1 border-b pb-2">
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||||
|
active={editor.isActive('bold')}
|
||||||
|
label="Bold"
|
||||||
|
>
|
||||||
|
<Bold className="h-4 w-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||||
|
active={editor.isActive('italic')}
|
||||||
|
label="Italic"
|
||||||
|
>
|
||||||
|
<Italic className="h-4 w-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||||
|
active={editor.isActive('strike')}
|
||||||
|
label="Strikethrough"
|
||||||
|
>
|
||||||
|
<Strikethrough className="h-4 w-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().toggleCode().run()}
|
||||||
|
active={editor.isActive('code')}
|
||||||
|
label="Code"
|
||||||
|
>
|
||||||
|
<Code className="h-4 w-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<div className="mx-1 w-px bg-border" />
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||||
|
active={editor.isActive('bulletList')}
|
||||||
|
label="Bullet list"
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||||
|
active={editor.isActive('orderedList')}
|
||||||
|
label="Ordered list"
|
||||||
|
>
|
||||||
|
<ListOrdered className="h-4 w-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||||
|
active={editor.isActive('blockquote')}
|
||||||
|
label="Blockquote"
|
||||||
|
>
|
||||||
|
<Quote className="h-4 w-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<div className="mx-1 w-px bg-border" />
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().undo().run()}
|
||||||
|
disabled={!editor.can().undo()}
|
||||||
|
label="Undo"
|
||||||
|
>
|
||||||
|
<Undo className="h-4 w-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().redo().run()}
|
||||||
|
disabled={!editor.can().redo()}
|
||||||
|
label="Redo"
|
||||||
|
>
|
||||||
|
<Redo className="h-4 w-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editor content */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<EditorContent editor={editor} className="tiptap-editor min-h-[400px]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolbarButton({
|
||||||
|
onClick,
|
||||||
|
active,
|
||||||
|
disabled,
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
onClick: () => void;
|
||||||
|
active?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
label: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn('h-8 w-8', active && 'bg-accent text-accent-foreground')}
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={label}
|
||||||
|
aria-pressed={active}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
|
const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<p className="text-sm text-muted-foreground">Loading graph...</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Note {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
domain: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GraphNode {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
domain: string;
|
||||||
|
val: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GraphLink {
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NoteGraphProps {
|
||||||
|
notes: Note[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NoteGraph({ notes }: NoteGraphProps) {
|
||||||
|
const [graphData, setGraphData] = useState<{
|
||||||
|
nodes: GraphNode[];
|
||||||
|
links: GraphLink[];
|
||||||
|
}>({ nodes: [], links: [] });
|
||||||
|
const graphRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [dimensions, setDimensions] = useState({ width: 300, height: 500 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchGraphData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (graphRef.current) {
|
||||||
|
const { width, height } = graphRef.current.getBoundingClientRect();
|
||||||
|
setDimensions({ width: Math.floor(width) || 300, height: Math.floor(height) || 500 });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function fetchGraphData() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/notes/graph');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const nodes: GraphNode[] = (data.nodes || []).map(
|
||||||
|
(node: { id: string; title: string; domain: string; connectionCount?: number }) => ({
|
||||||
|
id: node.id,
|
||||||
|
title: node.title,
|
||||||
|
domain: node.domain,
|
||||||
|
val: (node.connectionCount || 0) + 1,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const links: GraphLink[] = (data.edges || []).map(
|
||||||
|
(edge: { source: string; target: string }) => ({
|
||||||
|
source: edge.source,
|
||||||
|
target: edge.target,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
setGraphData({ nodes, links });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch graph data:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (graphData.nodes.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<p className="text-sm text-muted-foreground">No graph data available</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={graphRef} className="h-[500px] w-full">
|
||||||
|
<ForceGraph2D
|
||||||
|
graphData={graphData}
|
||||||
|
nodeLabel="title"
|
||||||
|
nodeAutoColorBy="domain"
|
||||||
|
nodeRelSize={6}
|
||||||
|
linkDirectionalArrowLength={6}
|
||||||
|
linkDirectionalArrowRelPos={0.99}
|
||||||
|
onNodeClick={(node: Record<string, unknown>) => {
|
||||||
|
console.log('Clicked node:', node);
|
||||||
|
}}
|
||||||
|
width={dimensions.width}
|
||||||
|
height={dimensions.height}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user