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
+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');
}