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:
2026-07-16 06:19:58 -04:00
parent ec14645a4b
commit 8f55626e03
286 changed files with 31992 additions and 9245 deletions
+14
View File
@@ -0,0 +1,14 @@
node_modules
.next
dist
build
.turbo
.git
*.md
.env*
.vscode
.idea
coverage
test-results
playwright-report
pocketbase/pb_data
+22
View File
@@ -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
View File
@@ -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/
+20
View File
@@ -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"]
+36
View File
@@ -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"]
+16
View File
@@ -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"]
+322 -68
View File
@@ -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 ```bash
npm install 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 npm run dev
npm run build
``` ```
This starter does not use `wrangler.jsonc`. This starts all packages via Turborepo:
- Web app at `http://localhost:3000`
- PocketBase at `http://localhost:8090`
- Worker (if configured)
## Included Shape 6. **Create your first user**
- edit site code under `app/` Open `http://localhost:3000` and sign up, or use the PocketBase admin UI at `http://localhost:8090/_/` to create users.
- `.openai/hosting.json` declares optional Sites D1 and R2 bindings
- `vite.config.ts` simulates declared bindings for local development
- `db/schema.ts` starts intentionally empty
- `examples/d1/` contains an optional D1 example surface
- `drizzle.config.ts` supports local migration generation when needed
## Workspace Auth Headers ### Production Deployment (Docker)
OpenAI workspace sites can read the current user's email from ```bash
`oai-authenticated-user-email`. # Build and start all containers
docker compose up -d
SIWC-authenticated workspace sites may also receive # Check status
`oai-authenticated-user-full-name` when the user's SIWC profile has a non-empty docker compose ps
`name` claim. The full-name value is percent-encoded UTF-8 and is accompanied by
`oai-authenticated-user-full-name-encoding: percent-encoded-utf-8`.
Treat the full name as optional and fall back to email when it is absent: # View logs
docker compose logs -f
```tsx # Stop all containers
import { headers } from "next/headers"; docker compose down
export default async function Home() {
const requestHeaders = await headers();
const email = requestHeaders.get("oai-authenticated-user-email");
const encodedFullName = requestHeaders.get("oai-authenticated-user-full-name");
const fullName =
encodedFullName &&
requestHeaders.get("oai-authenticated-user-full-name-encoding") ===
"percent-encoded-utf-8"
? decodeURIComponent(encodedFullName)
: null;
const displayName = fullName ?? email;
// ...
}
``` ```
## Optional Dispatch-Owned ChatGPT Sign-In The deployment starts three containers:
- **web:** Next.js app on port 3000
- **db:** PocketBase on port 8090
- **worker:** Background job processor
Import the ready-to-use helpers from `app/chatgpt-auth.ts` when the site needs ## Project Structure
optional or required ChatGPT sign-in:
- Use `getChatGPTUser()` for optional signed-in UI. ```
- Use `requireChatGPTUser(returnTo)` for server-rendered pages that should send project-e/
anonymous visitors through Sign in with ChatGPT. ├── apps/
- Use `chatGPTSignInPath(returnTo)` and `chatGPTSignOutPath(returnTo)` for │ └── web/ # Next.js application
browser links or actions. │ ├── app/ # App Router pages and API routes
- Pass a same-origin relative `returnTo` path for the destination after sign-in │ │ ├── (auth)/ # Auth pages (login, signup)
or sign-out. The helper validates and safely encodes it. │ │ ├── (dashboard)/ # Dashboard pages
- Mark protected pages with `export const dynamic = "force-dynamic"` because │ │ ├── api/ # REST API endpoints
they depend on per-request identity headers. │ │ │ ├── auth/ # Login, logout, refresh, me
│ │ │ ├── tasks/ # Task CRUD + bulk operations
│ │ │ ├── habits/ # Habit CRUD
│ │ │ ├── projects/ # Project CRUD
│ │ │ ├── notes/ # Note CRUD
│ │ │ ├── reports/ # Report CRUD
│ │ │ ├── milestones/ # Milestone CRUD
│ │ │ ├── domains/ # Domain CRUD
│ │ │ ├── tags/ # Tag CRUD
│ │ │ ├── agents/ # Agent CRUD
│ │ │ ├── webhooks/ # Webhook CRUD
│ │ │ ├── analytics/ # Analytics data
│ │ │ ├── realtime/ # SSE proxy for PocketBase
│ │ │ ├── mcp/ # MCP server endpoint
│ │ │ └── health/ # Health check
│ │ └── layout.tsx # Root layout
│ ├── components/ # React components (shadcn/ui)
│ ├── hooks/ # Custom React hooks
│ ├── lib/ # Utilities and services
│ │ ├── mcp/ # MCP server and tools
│ │ ├── services/ # Business logic services
│ │ ├── stores/ # Zustand stores
│ │ ├── events/ # Event bus
│ │ ├── auth.ts # Auth middleware
│ │ ├── pocketbase.ts # PocketBase client
│ │ └── errors.ts # Error handling
│ └── types/ # TypeScript type definitions
├── packages/
│ └── shared/ # Shared package
│ └── src/
│ ├── schemas/ # Zod validation schemas
│ ├── types/ # TypeScript types
│ └── constants/ # Shared constants
├── pocketbase/
│ ├── pb_migrations/ # Database migrations
│ └── schema.ts # TypeScript types for collections
├── worker/
│ └── index.ts # Background job worker
├── e2e/ # Playwright E2E tests
├── tests/ # Unit and component tests
├── docker-compose.yml # Docker Compose configuration
├── Dockerfile.web # Web container build
├── Dockerfile.pocketbase # PocketBase container build
├── Dockerfile.worker # Worker container build
├── turbo.json # Turborepo configuration
└── package.json # Root package.json
```
Dispatch owns `/signin-with-chatgpt`, `/signout-with-chatgpt`, `/callback`, the ## Available Scripts
OAuth cookies, and identity header injection. Do not implement app routes for
those reserved paths. Routes that do not import and call the helper remain
anonymous-compatible.
SIWC establishes identity only; it does not prove workspace membership. Use the | Command | Description |
Sites hosting platform's access policy controls for workspace-wide restrictions, |---------|-------------|
or enforce explicit server-side membership or allowlist checks. | `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 SIWC for account pages, user-specific dashboards, saved records, and write ## Environment Variables
actions tied to the current ChatGPT user. Leave public content anonymous.
## Useful Commands | Variable | Description | Default |
|----------|-------------|---------|
| `POCKETBASE_URL` | PocketBase server URL | `http://localhost:8090` |
| `POCKETBASE_ADMIN_TOKEN` | Admin authentication token | (required for worker) |
| `NODE_ENV` | Environment (`development`, `production`) | `development` |
- `npm run dev`: start local development Create a `.env.local` file in the root directory for local development.
- `npm run build`: verify the vinext build output
- `npm test`: build the starter and verify its rendered loading skeleton
- `npm run db:generate`: generate Drizzle migrations after schema changes
## Learn More ## Testing
- [vinext Documentation](https://github.com/cloudflare/vinext) ### Unit and Component Tests
- [Drizzle D1 Guide](https://orm.drizzle.team/docs/get-started/d1-new)
```bash
npm run test
```
Runs Jest tests across all packages. Tests are located in `tests/` and alongside components.
### E2E Tests
```bash
# Run all E2E tests
npm run test:e2e
# Run with UI mode (interactive)
npm run test:e2e:ui
# View test report
npm run test:e2e:report
```
Playwright tests are in `e2e/` and cover:
- Authentication flows
- Task management
- Habit tracking
- Project organization
- Note editing
- Report generation
- Analytics dashboards
- Navigation and settings
Tests run against five browser configurations: Chromium, Firefox, WebKit, Mobile Chrome, and Mobile Safari.
## Deployment
### Docker Compose (Recommended)
```bash
# Build and start
docker compose up -d
# View logs
docker compose logs -f
# Stop
docker compose down
# Rebuild after code changes
docker compose up -d --build
```
### Environment Configuration
Set these in your deployment environment:
```bash
POCKETBASE_URL=http://db:8090
POCKETBASE_ADMIN_TOKEN=your_secure_admin_token
```
### Volumes
- `project-e-pb-data`: PocketBase database files
- `project-e-web-uploads`: Uploaded files
### Health Checks
All containers include health checks:
- Web: `GET /api/health`
- PocketBase: `GET /api/health`
## Documentation
- [API Documentation](docs/API.md): REST API endpoints, authentication, error handling
- [MCP Server Documentation](docs/MCP.md): Model Context Protocol tools and usage
- [Deployment Guide](docs/DEPLOYMENT.md): Production deployment, SSL, backups, monitoring
- [Development Guide](docs/DEVELOPMENT.md): Contributing, code structure, testing strategy
- [Architecture Documentation](docs/ARCHITECTURE.md): System design, data flow, security
## Contributing
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Make your changes
4. Run tests (`npm run test && npm run test:e2e`)
5. Commit your changes (`git commit -m 'Add amazing feature'`)
6. Push to the branch (`git push origin feature/amazing-feature`)
7. Open a Pull Request
### Development Guidelines
- Write tests for new features
- Follow existing code style (TypeScript, functional components)
- Update documentation for API changes
- Keep commits atomic and well-described
- Use conventional commit messages
### Code Review Process
- All PRs require at least one review
- CI must pass (lint, typecheck, tests)
- Keep PRs focused on a single concern
- Write clear PR descriptions explaining the "why"
## License
This project is private and proprietary.
## Support
For issues and questions:
- Open an issue on GitHub
- Check the documentation in `docs/`
- Review existing issues for similar problems
+3
View File
@@ -0,0 +1,3 @@
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
+81
View File
@@ -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>
);
}
+352
View File
@@ -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>
);
}
+283
View File
@@ -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>
);
}
+285
View File
@@ -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>
);
}
+183
View File
@@ -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>
);
}
+158
View File
@@ -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&apos;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>
);
}
+27
View File
@@ -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>
);
}
+351
View File
@@ -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>
);
}
+176
View File
@@ -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>
);
}
+302
View File
@@ -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>
);
}
+101
View File
@@ -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>
);
}
+44
View File
@@ -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);
}
}
+24
View File
@@ -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,
});
});
+24
View File
@@ -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,
});
});
+45
View File
@@ -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 },
);
}
}
+46
View File
@@ -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 });
});
+49
View File
@@ -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;
}
});
+62
View File
@@ -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);
}
});
+52
View File
@@ -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 }
);
}
}
+16
View File
@@ -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;
}
+33
View File
@@ -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 }
);
}
}
+41
View File
@@ -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 }
);
}
}
+46
View File
@@ -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 });
});
+46
View File
@@ -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;
}
});
+46
View File
@@ -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 });
});
+46
View File
@@ -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;
}
});
+33
View File
@@ -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 });
});
+62
View File
@@ -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, ' '),
})),
});
});
+31
View File
@@ -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;
}
});
+46
View File
@@ -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 });
});
+53
View File
@@ -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;
}
});
+13
View File
@@ -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',
},
});
});
+9
View File
@@ -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',
});
}
+82
View File
@@ -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,
});
});
+127
View File
@@ -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;
}
+46
View File
@@ -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 });
});
+53
View File
@@ -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,
});
}
);
+58
View File
@@ -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 });
});
+236
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/** 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
);
}
});
+13
View File
@@ -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',
},
});
});
+61
View File
@@ -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 });
});
+46
View File
@@ -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 });
});
+53
View File
@@ -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;
}
});
+111
View File
@@ -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',
},
});
}
+46
View File
@@ -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 });
});
+46
View File
@@ -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;
}
});
+54
View File
@@ -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 });
});
+46
View File
@@ -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 });
});
+46
View File
@@ -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;
}
});
+46
View File
@@ -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 });
});
+71
View File
@@ -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);
});
+53
View File
@@ -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;
}
});
+55
View File
@@ -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,
});
});
+46
View File
@@ -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,
});
}
});
+46
View File
@@ -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;
}
});
+47
View File
@@ -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>
);
}
+332
View File
@@ -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;
}
+45
View File
@@ -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>
);
}
+32
View File
@@ -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&apos;re looking for doesn&apos;t exist or has been
moved.
</p>
</CardContent>
<CardFooter>
<Button asChild>
<Link href="/">Go home</Link>
</Button>
</CardFooter>
</Card>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Home() {
redirect('/dashboard');
}
+20
View File
@@ -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
/>
);
}
+205
View File
@@ -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&apos;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>
);
}
+97
View File
@@ -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&apos;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>
);
}
+163
View File
@@ -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>
);
}
+110
View File
@@ -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