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');
}
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
@@ -0,0 +1,345 @@
'use client';
import {
TrendingUp,
Clock,
Flame,
BarChart3,
PieChart as PieChartIcon,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
LineChart,
Line,
BarChart,
Bar,
PieChart,
Pie,
Cell,
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
interface TimeData {
date: string;
tasks: number;
habits: number;
time: number;
}
interface DomainData {
name: string;
value: number;
color: string;
}
interface HabitData {
name: string;
streak: number;
score: number;
consistency: number;
}
const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#06b6d4'];
const chartTooltipStyle = {
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '0.5rem',
};
interface AnalyticsChartsProps {
timeData: TimeData[];
domainData: DomainData[];
habitData: HabitData[];
activeTab: string;
}
export function AnalyticsCharts({
timeData,
domainData,
habitData,
activeTab,
}: AnalyticsChartsProps) {
if (activeTab === 'trends') {
return (
<div className="grid grid-cols-1 gap-6">
{/* Productivity trend */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" aria-hidden="true" />
Productivity Trend
</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={timeData}>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(var(--border))"
/>
<XAxis
dataKey="date"
stroke="hsl(var(--muted-foreground))"
fontSize={12}
/>
<YAxis
stroke="hsl(var(--muted-foreground))"
fontSize={12}
/>
<Tooltip contentStyle={chartTooltipStyle} />
<Legend />
<Area
type="monotone"
dataKey="tasks"
stackId="1"
stroke="#3b82f6"
fill="#3b82f6"
fillOpacity={0.6}
name="Tasks Completed"
/>
<Area
type="monotone"
dataKey="habits"
stackId="1"
stroke="#10b981"
fill="#10b981"
fillOpacity={0.6}
name="Habits Logged"
/>
</AreaChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* Time tracked trend */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" aria-hidden="true" />
Time Tracked
</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={timeData}>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(var(--border))"
/>
<XAxis
dataKey="date"
stroke="hsl(var(--muted-foreground))"
fontSize={12}
/>
<YAxis
stroke="hsl(var(--muted-foreground))"
fontSize={12}
label={{
value: 'Minutes',
angle: -90,
position: 'insideLeft',
}}
/>
<Tooltip contentStyle={chartTooltipStyle} />
<Line
type="monotone"
dataKey="time"
stroke="#8b5cf6"
strokeWidth={2}
dot={{ fill: '#8b5cf6', r: 3 }}
name="Time (minutes)"
/>
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
);
}
if (activeTab === 'habits') {
return (
<div className="grid grid-cols-1 gap-6">
{/* Habit streaks */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Flame className="h-5 w-5" aria-hidden="true" />
Habit Streaks
</CardTitle>
</CardHeader>
<CardContent>
{habitData.length === 0 ? (
<p className="py-8 text-center text-muted-foreground">
No habits tracked
</p>
) : (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={habitData} layout="vertical">
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(var(--border))"
/>
<XAxis
type="number"
stroke="hsl(var(--muted-foreground))"
fontSize={12}
/>
<YAxis
dataKey="name"
type="category"
stroke="hsl(var(--muted-foreground))"
fontSize={12}
width={120}
/>
<Tooltip contentStyle={chartTooltipStyle} />
<Bar
dataKey="streak"
fill="#f59e0b"
radius={[0, 4, 4, 0]}
name="Current Streak (days)"
/>
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
{/* Habit scores */}
<Card>
<CardHeader>
<CardTitle>Habit Scores</CardTitle>
</CardHeader>
<CardContent>
{habitData.length === 0 ? (
<p className="py-8 text-center text-muted-foreground">
No habits tracked
</p>
) : (
<div className="space-y-4">
{habitData.map((habit) => (
<div key={habit.name}>
<div className="mb-1 flex items-center justify-between">
<span className="text-sm font-medium">
{habit.name}
</span>
<span className="text-sm text-muted-foreground">
{habit.score}/100
</span>
</div>
<div className="h-2 rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${habit.score}%` }}
/>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}
if (activeTab === 'time') {
return (
<div className="grid grid-cols-1 gap-6">
{/* Time by domain */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<PieChartIcon className="h-5 w-5" aria-hidden="true" />
Time by Domain
</CardTitle>
</CardHeader>
<CardContent>
{domainData.length === 0 ? (
<p className="py-8 text-center text-muted-foreground">
No time tracked
</p>
) : (
<div className="flex items-center gap-8">
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={domainData}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percent }) =>
`${name}: ${((percent || 0) * 100).toFixed(0)}%`
}
outerRadius={100}
fill="#8884d8"
dataKey="value"
>
{domainData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip contentStyle={chartTooltipStyle} />
</PieChart>
</ResponsiveContainer>
</div>
)}
</CardContent>
</Card>
{/* Daily breakdown */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-5 w-5" aria-hidden="true" />
Daily Activity
</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={timeData.slice(-7)}>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(var(--border))"
/>
<XAxis
dataKey="date"
stroke="hsl(var(--muted-foreground))"
fontSize={12}
/>
<YAxis
stroke="hsl(var(--muted-foreground))"
fontSize={12}
/>
<Tooltip contentStyle={chartTooltipStyle} />
<Legend />
<Bar
dataKey="tasks"
fill="#3b82f6"
name="Tasks"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="habits"
fill="#10b981"
name="Habits"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
);
}
return null;
}
@@ -0,0 +1,67 @@
'use client';
import { Calendar, dateFnsLocalizer } from 'react-big-calendar';
import 'react-big-calendar/lib/css/react-big-calendar.css';
import { format, parse, startOfWeek, getDay } from 'date-fns';
import { enUS } from 'date-fns/locale/en-US';
const locales = {
'en-US': enUS,
};
const localizer = dateFnsLocalizer({
format,
parse,
startOfWeek,
getDay,
locales,
});
interface CalendarEvent {
id: string;
title: string;
start: Date;
end: Date;
type: 'task' | 'habit' | 'project' | 'milestone';
domain: string;
color: string;
}
interface BigCalendarWrapperProps {
events: CalendarEvent[];
}
function eventStyleGetter(event: CalendarEvent) {
return {
style: {
backgroundColor: event.color,
borderRadius: '4px',
opacity: 0.8,
color: 'white',
border: '0px',
fontSize: '12px',
},
};
}
function handleSelectEvent(event: CalendarEvent) {
console.log('Selected event:', event);
}
export function BigCalendarWrapper({ events }: BigCalendarWrapperProps) {
return (
<Calendar
localizer={localizer}
events={events}
startAccessor="start"
endAccessor="end"
style={{ height: 600 }}
eventPropGetter={eventStyleGetter}
onSelectEvent={handleSelectEvent}
views={['month', 'week', 'day']}
defaultView="month"
popup
toolbar
/>
);
}
+205
View File
@@ -0,0 +1,205 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import {
LayoutDashboard,
ListTodo,
Flame,
FolderKanban,
NotebookPen,
FileBarChart,
CalendarDays,
BarChart3,
Bot,
Settings,
Plus,
Search,
type LucideIcon,
} from 'lucide-react';
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command';
interface NavItem {
label: string;
href: string;
icon: LucideIcon;
}
const navItems: NavItem[] = [
{ label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
{ label: 'Tasks', href: '/tasks', icon: ListTodo },
{ label: 'Habits', href: '/habits', icon: Flame },
{ label: 'Projects', href: '/projects', icon: FolderKanban },
{ label: 'Notes', href: '/notes', icon: NotebookPen },
{ label: 'Reports', href: '/reports', icon: FileBarChart },
{ label: 'Calendar', href: '/calendar', icon: CalendarDays },
{ label: 'Analytics', href: '/analytics', icon: BarChart3 },
{ label: 'Agent Activity', href: '/agents', icon: Bot },
{ label: 'Settings', href: '/settings', icon: Settings },
];
interface QuickAction {
label: string;
shortcut?: string;
action: () => void;
}
export function CommandPalette() {
const router = useRouter();
const [open, setOpen] = useState(false);
const [deepSearch, setDeepSearch] = useState(false);
const [searchResults, setSearchResults] = useState<Array<{
type: string;
items: Array<{ id: string; title: string }>;
}>>([]);
// Keyboard shortcuts
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
if (e.shiftKey) {
setDeepSearch(true);
setOpen(true);
} else {
setDeepSearch(false);
setOpen(true);
}
}
};
document.addEventListener('keydown', down);
return () => document.removeEventListener('keydown', down);
}, []);
// Quick actions
const quickActions: QuickAction[] = [
{ label: 'New task', shortcut: 'N', action: () => router.push('/tasks?new=true') },
{ label: 'New habit', action: () => router.push('/habits?new=true') },
{ label: 'New project', action: () => router.push('/projects?new=true') },
{ label: 'New note', action: () => router.push('/notes?new=true') },
{ label: 'New report', action: () => router.push('/reports?new=true') },
];
// Search handler
const handleSearch = useCallback(async (query: string) => {
if (!query.trim()) {
setSearchResults([]);
return;
}
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=5`);
if (response.ok) {
const data = await response.json();
setSearchResults(data.results || []);
}
} catch {
// Ignore search errors
}
}, []);
const runCommand = useCallback((command: () => void) => {
setOpen(false);
command();
}, []);
return (
<CommandDialog
open={open}
onOpenChange={setOpen}
label="Command palette"
className={deepSearch ? 'max-w-2xl' : 'max-w-lg'}
>
<CommandInput
placeholder={deepSearch ? 'Search everything...' : 'Type a command or search...'}
onValueChange={handleSearch}
/>
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
{/* Navigation */}
{!deepSearch && (
<CommandGroup heading="Jump to">
{navItems.map((item) => (
<CommandItem
key={item.href}
onSelect={() => runCommand(() => router.push(item.href))}
>
<item.icon className="mr-2 h-4 w-4" />
{item.label}
</CommandItem>
))}
</CommandGroup>
)}
{/* Quick Actions */}
<CommandGroup heading="Quick actions">
{quickActions.map((action) => (
<CommandItem
key={action.label}
onSelect={() => runCommand(action.action)}
>
<Plus className="mr-2 h-4 w-4" />
{action.label}
{action.shortcut && (
<kbd className="ml-auto pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
{action.shortcut}
</kbd>
)}
</CommandItem>
))}
</CommandGroup>
{/* Search Results (deep search mode) */}
{deepSearch && searchResults.length > 0 && (
<>
<CommandSeparator />
{searchResults.map((group) => (
<CommandGroup key={group.type} heading={group.type}>
{group.items.map((item) => (
<CommandItem
key={item.id}
onSelect={() => {
const typeRoute =
group.type === 'tasks' ? '/tasks' :
group.type === 'habits' ? '/habits' :
group.type === 'projects' ? '/projects' :
group.type === 'notes' ? '/notes' :
'/reports';
runCommand(() => router.push(`${typeRoute}/${item.id}`));
}}
>
<Search className="mr-2 h-4 w-4" />
{item.title}
</CommandItem>
))}
</CommandGroup>
))}
</>
)}
{/* Footer hint */}
<div className="flex items-center justify-between border-t px-3 py-2 text-xs text-muted-foreground">
<span>
<kbd className="rounded border bg-muted px-1"></kbd> navigate
</span>
<span>
<kbd className="rounded border bg-muted px-1"></kbd> select
</span>
<span>
<kbd className="rounded border bg-muted px-1">esc</kbd> close
</span>
</div>
</CommandList>
</CommandDialog>
);
}
@@ -0,0 +1,51 @@
'use client';
import ReactGridLayout from 'react-grid-layout';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
// WidthProvider and Responsive are namespace exports from react-grid-layout.
// With @types/react-grid-layout's `export =` pattern, we access them via the module.
const WidthProvider = (
ReactGridLayout as unknown as {
WidthProvider: <P extends React.ComponentType<React.ComponentProps<P>>>(
component: P
) => React.ComponentType<React.ComponentProps<P> & { measureBeforeMount?: boolean }>;
}
).WidthProvider;
const Responsive = (
ReactGridLayout as unknown as {
Responsive: React.ComponentType<ReactGridLayout.ResponsiveProps>;
}
).Responsive;
const ResponsiveGridLayout = WidthProvider(Responsive);
interface ResponsiveGridProps {
layout: ReactGridLayout.Layout[];
onLayoutChange: (newLayout: ReactGridLayout.Layout[]) => void;
children: React.ReactNode;
}
export default function ResponsiveGrid({
layout,
onLayoutChange,
children,
}: ResponsiveGridProps) {
return (
<ResponsiveGridLayout
className="layout"
layouts={{ lg: layout }}
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
rowHeight={80}
onLayoutChange={onLayoutChange}
draggableHandle=".widget-drag-handle"
compactType="vertical"
isResizable
>
{children}
</ResponsiveGridLayout>
);
}
@@ -0,0 +1,57 @@
'use client';
import { Calendar } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
export function CalendarMiniWidget() {
const today = new Date();
const daysInMonth = new Date(
today.getFullYear(),
today.getMonth() + 1,
0
).getDate();
const firstDay = new Date(
today.getFullYear(),
today.getMonth(),
1
).getDay();
return (
<Card className="h-full border-0 shadow-none">
<CardHeader className="p-0 pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Calendar className="h-4 w-4" aria-hidden="true" />
{today.toLocaleString('default', { month: 'long' })}
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="grid grid-cols-7 gap-1 text-center text-xs">
{['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((day, i) => (
<div key={i} className="font-semibold text-muted-foreground">
{day}
</div>
))}
{Array.from({ length: firstDay }).map((_, i) => (
<div key={`empty-${i}`} />
))}
{Array.from({ length: daysInMonth }).map((_, i) => {
const day = i + 1;
const isToday = day === today.getDate();
return (
<div
key={day}
className={`rounded p-1 ${
isToday
? 'bg-primary text-primary-foreground font-semibold'
: ''
}`}
>
{day}
</div>
);
})}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,98 @@
'use client';
import { useEffect, useState } from 'react';
import { Flame } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
interface Habit {
id: string;
name: string;
current_streak: number;
logged_today: boolean;
}
export function HabitChecklistWidget() {
const [habits, setHabits] = useState<Habit[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchHabits();
}, []);
async function fetchHabits() {
try {
const response = await fetch('/api/habits');
if (response.ok) {
const data = await response.json();
setHabits(data.items || []);
}
} catch (error) {
console.error('Failed to fetch habits:', error);
} finally {
setLoading(false);
}
}
async function toggleHabit(id: string) {
try {
await fetch(`/api/habits/${id}/logs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
fetchHabits();
} catch (error) {
console.error('Failed to log habit:', error);
}
}
const completedCount = habits.filter((h) => h.logged_today).length;
return (
<Card className="h-full border-0 shadow-none">
<CardHeader className="p-0 pb-3">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-base">
<Flame className="h-4 w-4 text-orange-500" aria-hidden="true" />
Habits
</CardTitle>
<span className="text-xs text-muted-foreground">
{completedCount}/{habits.length} done
</span>
</div>
</CardHeader>
<CardContent className="p-0">
{loading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : habits.length === 0 ? (
<p className="text-sm text-muted-foreground">No habits tracked</p>
) : (
<div className="space-y-2">
{habits.slice(0, 5).map((habit) => (
<div key={habit.id} className="flex items-center gap-2">
<Checkbox
id={habit.id}
checked={habit.logged_today}
onCheckedChange={() => toggleHabit(habit.id)}
aria-label={`Mark "${habit.name}" as ${habit.logged_today ? 'incomplete' : 'complete'}`}
/>
<label
htmlFor={habit.id}
className="flex-1 text-sm cursor-pointer"
>
{habit.name}
</label>
{habit.current_streak > 0 && (
<span className="text-xs text-muted-foreground">
🔥 {habit.current_streak}
</span>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,62 @@
'use client';
import { useEffect, useState } from 'react';
import { Flame } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
interface Streak {
habit: { name: string };
streak_current: number;
}
export function HabitStreaksWidget() {
const [streaks, setStreaks] = useState<Streak[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchStreaks();
}, []);
async function fetchStreaks() {
try {
const response = await fetch('/api/habits/streaks');
if (response.ok) {
const data = await response.json();
setStreaks(data.streaks || []);
}
} catch (error) {
console.error('Failed to fetch streaks:', error);
} finally {
setLoading(false);
}
}
return (
<Card className="h-full border-0 shadow-none">
<CardHeader className="p-0 pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Flame className="h-4 w-4 text-orange-500" aria-hidden="true" />
Top Streaks
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{loading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : streaks.length === 0 ? (
<p className="text-sm text-muted-foreground">No active streaks</p>
) : (
<div className="space-y-2">
{streaks.slice(0, 5).map((streak, i) => (
<div key={i} className="flex items-center justify-between">
<span className="text-sm">{streak.habit.name}</span>
<span className="text-sm font-semibold">
🔥 {streak.streak_current}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,69 @@
'use client';
import { useEffect, useState } from 'react';
import { FolderKanban } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
interface Project {
id: string;
name: string;
progress: number;
}
export function ProjectProgressWidget() {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchProjects();
}, []);
async function fetchProjects() {
try {
const response = await fetch(
'/api/projects?filter=status%3D%22active%22&perPage=5'
);
if (response.ok) {
const data = await response.json();
setProjects(data.items || []);
}
} catch (error) {
console.error('Failed to fetch projects:', error);
} finally {
setLoading(false);
}
}
return (
<Card className="h-full border-0 shadow-none">
<CardHeader className="p-0 pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<FolderKanban className="h-4 w-4" aria-hidden="true" />
Active Projects
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{loading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : projects.length === 0 ? (
<p className="text-sm text-muted-foreground">No active projects</p>
) : (
<div className="space-y-3">
{projects.map((project) => (
<div key={project.id}>
<div className="mb-1 flex items-center justify-between">
<span className="text-sm">{project.name}</span>
<span className="text-xs text-muted-foreground">
{project.progress}%
</span>
</div>
<Progress value={project.progress} className="h-2" />
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,52 @@
'use client';
import { Plus } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
export function QuickAddWidget() {
function handleQuickAdd() {
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'k', metaKey: true })
);
}
return (
<Card className="h-full border-0 shadow-none">
<CardHeader className="p-0 pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Plus className="h-4 w-4" aria-hidden="true" />
Quick Add
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="flex flex-col gap-2">
<Button
variant="outline"
size="sm"
className="justify-start"
onClick={handleQuickAdd}
>
New task
</Button>
<Button
variant="outline"
size="sm"
className="justify-start"
onClick={handleQuickAdd}
>
New habit
</Button>
<Button
variant="outline"
size="sm"
className="justify-start"
onClick={handleQuickAdd}
>
New note
</Button>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,22 @@
'use client';
import { Activity } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
export function RecentActivityWidget() {
return (
<Card className="h-full border-0 shadow-none">
<CardHeader className="p-0 pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Activity className="h-4 w-4" aria-hidden="true" />
Recent Activity
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<p className="text-sm text-muted-foreground">
Activity feed coming soon
</p>
</CardContent>
</Card>
);
}
@@ -0,0 +1,109 @@
'use client';
import { useEffect, useState } from 'react';
import { CheckCircle2, Circle, ListTodo } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
interface Task {
id: string;
title: string;
status: string;
priority: string;
domain: string;
}
export function TodayTasksWidget() {
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchTasks();
}, []);
async function fetchTasks() {
try {
const response = await fetch(
'/api/tasks?filter=status!%3D%22done%22&perPage=5&sort=-priority'
);
if (response.ok) {
const data = await response.json();
setTasks(data.items || []);
}
} catch (error) {
console.error('Failed to fetch tasks:', error);
} finally {
setLoading(false);
}
}
async function toggleTask(id: string, currentStatus: string) {
const newStatus = currentStatus === 'done' ? 'todo' : 'done';
try {
await fetch(`/api/tasks/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus }),
});
fetchTasks();
} catch (error) {
console.error('Failed to toggle task:', error);
}
}
return (
<Card className="h-full border-0 shadow-none">
<CardHeader className="p-0 pb-3">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-base">
<ListTodo className="h-4 w-4" aria-hidden="true" />
Today&apos;s Tasks
</CardTitle>
<Button variant="ghost" size="sm" className="h-7 text-xs">
View all
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
{loading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : tasks.length === 0 ? (
<p className="text-sm text-muted-foreground">No tasks for today</p>
) : (
<div className="space-y-2">
{tasks.map((task) => (
<div key={task.id} className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="h-11 w-11 shrink-0"
onClick={() => toggleTask(task.id, task.status)}
aria-label={`Mark "${task.title}" as ${task.status === 'done' ? 'incomplete' : 'complete'}`}
>
{task.status === 'done' ? (
<CheckCircle2 className="h-4 w-4 text-green-600" />
) : (
<Circle className="h-4 w-4" />
)}
</Button>
<span
className={`flex-1 text-sm ${
task.status === 'done'
? 'line-through text-muted-foreground'
: ''
}`}
>
{task.title}
</span>
<Badge variant="outline" className="text-xs">
{task.domain}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,84 @@
'use client';
import { useEffect, useState } from 'react';
import { BarChart3, TrendingUp } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
interface WeeklyStats {
taskCompletionRate: number;
habitConsistency: number;
totalTimeMinutes: number;
}
export function WeeklyStatsWidget() {
const [stats, setStats] = useState<WeeklyStats>({
taskCompletionRate: 0,
habitConsistency: 0,
totalTimeMinutes: 0,
});
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchStats();
}, []);
async function fetchStats() {
try {
const response = await fetch('/api/analytics?period=7');
if (response.ok) {
const data = await response.json();
setStats(data);
}
} catch (error) {
console.error('Failed to fetch stats:', error);
} finally {
setLoading(false);
}
}
return (
<Card className="h-full border-0 shadow-none">
<CardHeader className="p-0 pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<BarChart3 className="h-4 w-4" aria-hidden="true" />
This Week
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{loading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Task completion
</span>
<div className="flex items-center gap-1">
<span className="text-sm font-semibold">
{stats.taskCompletionRate}%
</span>
<TrendingUp className="h-3 w-3 text-green-600" />
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Habit consistency
</span>
<span className="text-sm font-semibold">
{stats.habitConsistency}%
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Time tracked
</span>
<span className="text-sm font-semibold">
{Math.round(stats.totalTimeMinutes / 60)}h
</span>
</div>
</div>
)}
</CardContent>
</Card>
);
}
+97
View File
@@ -0,0 +1,97 @@
'use client';
import { Flame, CheckCircle2, Circle } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
interface HabitCardProps {
habit: {
id: string;
name: string;
description?: string;
frequency: 'daily' | 'weekly' | 'custom';
current_streak: number;
best_streak: number;
score: number;
completion_mode: 'quick' | 'detailed';
domain: string;
logged_today: boolean;
};
onComplete: () => void;
}
export function HabitCard({ habit, onComplete }: HabitCardProps) {
return (
<Card className="relative overflow-hidden">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="text-base">{habit.name}</CardTitle>
{habit.description && (
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">
{habit.description}
</p>
)}
</div>
<Badge variant="outline" className="ml-2 shrink-0">
{habit.domain}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Streak info */}
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-1">
<Flame className="h-4 w-4 text-orange-500" aria-hidden="true" />
<span className="font-semibold">{habit.current_streak}</span>
<span className="text-muted-foreground">day streak</span>
</div>
<span className="text-xs text-muted-foreground">
Best: {habit.best_streak}
</span>
</div>
{/* Score */}
<div>
<div className="mb-1 flex items-center justify-between text-xs">
<span className="text-muted-foreground">Score</span>
<span className="font-semibold">{habit.score}/100</span>
</div>
<Progress value={habit.score} className="h-2" />
</div>
{/* Frequency badge */}
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs">
{habit.frequency}
</Badge>
<Badge variant="secondary" className="text-xs">
{habit.completion_mode}
</Badge>
</div>
{/* Complete button */}
<Button
onClick={onComplete}
variant={habit.logged_today ? 'outline' : 'default'}
className="w-full"
disabled={habit.logged_today}
>
{habit.logged_today ? (
<>
<CheckCircle2 className="mr-2 h-4 w-4 text-green-600" />
Completed today
</>
) : (
<>
<Circle className="mr-2 h-4 w-4" />
Mark complete
</>
)}
</Button>
</CardContent>
</Card>
);
}
@@ -0,0 +1,128 @@
'use client';
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
interface Habit {
id: string;
name: string;
}
interface HabitCompletionDialogProps {
habit: Habit;
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (data: { mood?: number; value?: number; notes?: string }) => void;
}
const moods = [
{ value: 5, label: 'Great' },
{ value: 4, label: 'Good' },
{ value: 3, label: 'Okay' },
{ value: 2, label: 'Meh' },
{ value: 1, label: 'Bad' },
];
export function HabitCompletionDialog({
habit,
open,
onOpenChange,
onSubmit,
}: HabitCompletionDialogProps) {
const [mood, setMood] = useState<number | undefined>();
const [quantity, setQuantity] = useState<number | undefined>();
const [notes, setNotes] = useState('');
function handleSubmit() {
onSubmit({
mood,
value: quantity,
notes: notes || undefined,
});
setMood(undefined);
setQuantity(undefined);
setNotes('');
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Log {habit.name}</DialogTitle>
<DialogDescription>
How did it go? (optional you can skip and just log completion)
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Mood picker */}
<div className="space-y-2">
<Label>Mood</Label>
<div className="flex gap-2">
{moods.map((m) => (
<Button
key={m.value}
variant={mood === m.value ? 'default' : 'outline'}
size="sm"
onClick={() => setMood(m.value)}
className="flex-1"
>
{m.label}
</Button>
))}
</div>
</div>
{/* Quantity */}
<div className="space-y-2">
<Label htmlFor="quantity">Quantity (optional)</Label>
<Input
id="quantity"
type="number"
placeholder="e.g., 30 minutes, 10 pages"
value={quantity ?? ''}
onChange={(e) =>
setQuantity(e.target.value ? Number(e.target.value) : undefined)
}
/>
</div>
{/* Notes */}
<div className="space-y-2">
<Label htmlFor="notes">Notes (optional)</Label>
<Textarea
id="notes"
placeholder="Any thoughts or reflections..."
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
/>
</div>
</div>
<div className="flex gap-2">
<Button onClick={handleSubmit} className="flex-1">
Log completion
</Button>
<Button
variant="outline"
onClick={() => onSubmit({})}
className="flex-1"
>
Skip
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,92 @@
'use client';
import { useEffect, useState } from 'react';
import CalendarHeatmap from 'react-calendar-heatmap';
import type { Habit } from '@project-e/shared';
interface HeatmapValue {
date: Date | string;
count: number;
}
interface HabitHeatmapProps {
habits: Habit[];
}
export function HabitHeatmap({ habits }: HabitHeatmapProps) {
const [values, setValues] = useState<HeatmapValue[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchHeatmapData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [habits.length]);
async function fetchHeatmapData() {
try {
const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
const response = await fetch(
`/api/habit-logs?start=${oneYearAgo.toISOString()}`
);
if (response.ok) {
const data = await response.json();
const logs: Array<{ logged_at: string }> = data.items || [];
// Group by date
const byDate: Record<string, number> = {};
logs.forEach((log) => {
const date = new Date(log.logged_at).toISOString().split('T')[0];
byDate[date] = (byDate[date] || 0) + 1;
});
const heatmapValues: HeatmapValue[] = Object.entries(byDate).map(
([date, count]) => ({
date,
count,
})
);
setValues(heatmapValues);
}
} catch (error) {
console.error('Failed to fetch heatmap data:', error);
} finally {
setLoading(false);
}
}
if (loading) {
return <p className="text-sm text-muted-foreground">Loading...</p>;
}
const today = new Date();
const oneYearAgo = new Date();
oneYearAgo.setFullYear(today.getFullYear() - 1);
return (
<div className="overflow-x-auto">
<CalendarHeatmap
startDate={oneYearAgo}
endDate={today}
values={values}
classForValue={(value) => {
if (!value || value.count === 0) return 'heatmap-empty';
if (value.count <= 1) return 'heatmap-scale-1';
if (value.count <= 2) return 'heatmap-scale-2';
if (value.count <= 3) return 'heatmap-scale-3';
return 'heatmap-scale-4';
}}
tooltipDataAttrs={(value) => {
if (!value || value.count === 0) return null;
const date = new Date(value.date).toLocaleDateString();
return {
'data-tip': `${date}: ${value.count} habit${value.count === 1 ? '' : 's'}`,
};
}}
showWeekdayLabels
/>
</div>
);
}
@@ -0,0 +1,15 @@
'use client';
import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts';
import { ShortcutsHelp } from '@/components/shortcuts-help';
export function KeyboardShortcutsProvider({ children }: { children: React.ReactNode }) {
useKeyboardShortcuts();
return (
<>
{children}
<ShortcutsHelp />
</>
);
}
@@ -0,0 +1,48 @@
'use client';
import { useEffect, useState } from 'react';
import { AlertCircle, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
export function NetworkErrorBanner() {
const [isOffline, setIsOffline] = useState(false);
useEffect(() => {
const handleOffline = () => setIsOffline(true);
const handleOnline = () => setIsOffline(false);
window.addEventListener('offline', handleOffline);
window.addEventListener('online', handleOnline);
setIsOffline(!navigator.onLine);
return () => {
window.removeEventListener('offline', handleOffline);
window.removeEventListener('online', handleOnline);
};
}, []);
if (!isOffline) return null;
return (
<div
className="fixed top-0 left-0 right-0 z-50 flex items-center justify-center gap-3 bg-destructive px-4 py-2 text-destructive-foreground shadow-lg"
role="alert"
aria-live="assertive"
>
<AlertCircle className="h-4 w-4" aria-hidden="true" />
<span className="text-sm font-medium">
You&apos;re offline. Some features may not work.
</span>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 px-2 text-xs"
onClick={() => window.location.reload()}
>
<RefreshCw className="h-3 w-3" aria-hidden="true" />
Retry
</Button>
</div>
);
}
@@ -0,0 +1,104 @@
'use client';
import { useState, useCallback } from 'react';
import { CalendarDays, ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { format, addDays, subDays } from 'date-fns';
interface DailyNoteButtonProps {
/** Called after the daily note is created/retrieved, with the raw PocketBase record. */
onNoteReady: (note: Record<string, unknown>) => void;
/** Optional: currently selected date (controls the displayed date). */
selectedDate?: Date;
/** Called when the user navigates to a different date. */
onDateChange?: (date: Date) => void;
}
/**
* Button row that creates / navigates daily notes.
*
* Layout: [CalendarDays · 2026-07-15]
*
* Clicking the centre button POSTs to /api/notes/daily and opens the note.
* The arrow buttons shift the date by one day without fetching.
*/
export function DailyNoteButton({
onNoteReady,
selectedDate,
onDateChange,
}: DailyNoteButtonProps) {
const [loading, setLoading] = useState(false);
const [currentDate, setCurrentDate] = useState<Date>(
selectedDate ?? new Date()
);
const dateStr = format(currentDate, 'yyyy-MM-dd');
const displayDate = format(currentDate, 'MMM d, yyyy');
const navigate = useCallback(
(delta: number) => {
const next = delta > 0 ? addDays(currentDate, 1) : subDays(currentDate, 1);
setCurrentDate(next);
onDateChange?.(next);
},
[currentDate, onDateChange]
);
async function handleCreateDailyNote() {
setLoading(true);
try {
const res = await fetch('/api/notes/daily', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ date: dateStr }),
});
if (!res.ok) {
console.error('Failed to create daily note', await res.text());
return;
}
const note = await res.json();
onNoteReady(note);
} catch (err) {
console.error('Failed to create daily note:', err);
} finally {
setLoading(false);
}
}
return (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
onClick={() => navigate(-1)}
aria-label="Previous day"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={handleCreateDailyNote}
disabled={loading}
>
<CalendarDays className="h-4 w-4" />
{loading ? 'Creating…' : `Daily Note — ${displayDate}`}
</Button>
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
onClick={() => navigate(1)}
aria-label="Next day"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
);
}
+163
View File
@@ -0,0 +1,163 @@
'use client';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder';
import { useEffect } from 'react';
import { Button } from '@/components/ui/button';
import {
Bold,
Italic,
Strikethrough,
Code,
List,
ListOrdered,
Quote,
Undo,
Redo,
} from 'lucide-react';
import { cn } from '@/lib/utils';
interface NoteEditorProps {
content: string;
onChange: (content: string) => void;
onBlur?: () => void;
}
export function NoteEditor({ content, onChange, onBlur }: NoteEditorProps) {
const editor = useEditor({
extensions: [
StarterKit,
Link.configure({
openOnClick: false,
}),
Placeholder.configure({
placeholder: 'Start writing... Use [[Note Title]] to link to other notes',
}),
],
content,
onUpdate: ({ editor: e }) => {
onChange(e.getHTML());
},
onBlur: () => {
onBlur?.();
},
});
useEffect(() => {
if (editor && content !== editor.getHTML()) {
editor.commands.setContent(content);
}
}, [content, editor]);
if (!editor) {
return null;
}
return (
<div className="flex h-full flex-col">
{/* Toolbar */}
<div className="mb-4 flex flex-wrap gap-1 border-b pb-2">
<ToolbarButton
onClick={() => editor.chain().focus().toggleBold().run()}
active={editor.isActive('bold')}
label="Bold"
>
<Bold className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleItalic().run()}
active={editor.isActive('italic')}
label="Italic"
>
<Italic className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleStrike().run()}
active={editor.isActive('strike')}
label="Strikethrough"
>
<Strikethrough className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleCode().run()}
active={editor.isActive('code')}
label="Code"
>
<Code className="h-4 w-4" />
</ToolbarButton>
<div className="mx-1 w-px bg-border" />
<ToolbarButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
active={editor.isActive('bulletList')}
label="Bullet list"
>
<List className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
active={editor.isActive('orderedList')}
label="Ordered list"
>
<ListOrdered className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
active={editor.isActive('blockquote')}
label="Blockquote"
>
<Quote className="h-4 w-4" />
</ToolbarButton>
<div className="mx-1 w-px bg-border" />
<ToolbarButton
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
label="Undo"
>
<Undo className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
label="Redo"
>
<Redo className="h-4 w-4" />
</ToolbarButton>
</div>
{/* Editor content */}
<div className="flex-1 overflow-auto">
<EditorContent editor={editor} className="tiptap-editor min-h-[400px]" />
</div>
</div>
);
}
function ToolbarButton({
onClick,
active,
disabled,
label,
children,
}: {
onClick: () => void;
active?: boolean;
disabled?: boolean;
label: string;
children: React.ReactNode;
}) {
return (
<Button
variant="ghost"
size="icon"
className={cn('h-8 w-8', active && 'bg-accent text-accent-foreground')}
onClick={onClick}
disabled={disabled}
aria-label={label}
aria-pressed={active}
>
{children}
</Button>
);
}
+110
View File
@@ -0,0 +1,110 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import dynamic from 'next/dynamic';
const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), {
ssr: false,
loading: () => (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">Loading graph...</p>
</div>
),
});
interface Note {
id: string;
title: string;
domain: string;
}
interface GraphNode {
id: string;
title: string;
domain: string;
val: number;
}
interface GraphLink {
source: string;
target: string;
}
interface NoteGraphProps {
notes: Note[];
}
export function NoteGraph({ notes }: NoteGraphProps) {
const [graphData, setGraphData] = useState<{
nodes: GraphNode[];
links: GraphLink[];
}>({ nodes: [], links: [] });
const graphRef = useRef<HTMLDivElement>(null);
const [dimensions, setDimensions] = useState({ width: 300, height: 500 });
useEffect(() => {
fetchGraphData();
}, []);
useEffect(() => {
if (graphRef.current) {
const { width, height } = graphRef.current.getBoundingClientRect();
setDimensions({ width: Math.floor(width) || 300, height: Math.floor(height) || 500 });
}
}, []);
async function fetchGraphData() {
try {
const response = await fetch('/api/notes/graph');
if (response.ok) {
const data = await response.json();
const nodes: GraphNode[] = (data.nodes || []).map(
(node: { id: string; title: string; domain: string; connectionCount?: number }) => ({
id: node.id,
title: node.title,
domain: node.domain,
val: (node.connectionCount || 0) + 1,
})
);
const links: GraphLink[] = (data.edges || []).map(
(edge: { source: string; target: string }) => ({
source: edge.source,
target: edge.target,
})
);
setGraphData({ nodes, links });
}
} catch (error) {
console.error('Failed to fetch graph data:', error);
}
}
if (graphData.nodes.length === 0) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">No graph data available</p>
</div>
);
}
return (
<div ref={graphRef} className="h-[500px] w-full">
<ForceGraph2D
graphData={graphData}
nodeLabel="title"
nodeAutoColorBy="domain"
nodeRelSize={6}
linkDirectionalArrowLength={6}
linkDirectionalArrowRelPos={0.99}
onNodeClick={(node: Record<string, unknown>) => {
console.log('Clicked node:', node);
}}
width={dimensions.width}
height={dimensions.height}
/>
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
'use client';
import {
createContext,
useContext,
useCallback,
useState,
useRef,
type ReactNode,
} from 'react';
import { useRealtime, type RealtimeEvent } from '@/hooks/use-realtime';
type EventCallback = (event: RealtimeEvent) => void;
interface RealtimeContextType {
connected: boolean;
error: string | null;
reconnect: () => void;
disconnect: () => void;
subscribe: (
collections: string[],
callback: EventCallback
) => () => void;
}
const RealtimeContext = createContext<RealtimeContextType | null>(null);
export function RealtimeProvider({ children }: { children: ReactNode }) {
const subscribersRef = useRef<Map<string, Set<EventCallback>>>(new Map());
const [, forceRender] = useState(0);
const handleEvent = useCallback((event: RealtimeEvent) => {
const collection = event.collection || '*';
const collectionSubs = subscribersRef.current.get(collection);
const globalSubs = subscribersRef.current.get('*');
if (collectionSubs) {
collectionSubs.forEach((cb) => cb(event));
}
if (globalSubs) {
globalSubs.forEach((cb) => cb(event));
}
}, []);
const { connected, error, reconnect, disconnect } = useRealtime({
onEvent: handleEvent,
});
const subscribe = useCallback(
(collections: string[], callback: EventCallback) => {
for (const collection of collections) {
if (!subscribersRef.current.has(collection)) {
subscribersRef.current.set(collection, new Set());
}
subscribersRef.current.get(collection)!.add(callback);
}
// Also register as a global subscriber
if (!subscribersRef.current.has('*')) {
subscribersRef.current.set('*', new Set());
}
subscribersRef.current.get('*')!.add(callback);
forceRender((n) => n + 1);
// Return unsubscribe function
return () => {
for (const collection of collections) {
subscribersRef.current.get(collection)?.delete(callback);
}
subscribersRef.current.get('*')?.delete(callback);
forceRender((n) => n + 1);
};
},
[]
);
return (
<RealtimeContext.Provider
value={{ connected, error, reconnect, disconnect, subscribe }}
>
{children}
</RealtimeContext.Provider>
);
}
export function useRealtimeContext() {
const context = useContext(RealtimeContext);
if (!context) {
throw new Error(
'useRealtimeContext must be used within a RealtimeProvider'
);
}
return context;
}
@@ -0,0 +1,180 @@
'use client';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder';
import { useEffect } from 'react';
import { Button } from '@/components/ui/button';
import {
Bold,
Italic,
Strikethrough,
Code,
List,
ListOrdered,
Quote,
Undo,
Redo,
Heading1,
Heading2,
} from 'lucide-react';
import { cn } from '@/lib/utils';
interface ReportEditorProps {
content: string;
onChange: (content: string) => void;
onBlur?: () => void;
}
function ToolbarButton({
onClick,
active,
disabled,
label,
children,
}: {
onClick: () => void;
active?: boolean;
disabled?: boolean;
label: string;
children: React.ReactNode;
}) {
return (
<Button
variant="ghost"
size="icon"
className={cn('h-8 w-8', active && 'bg-accent text-accent-foreground')}
onClick={onClick}
disabled={disabled}
aria-label={label}
aria-pressed={active}
>
{children}
</Button>
);
}
export function ReportEditor({ content, onChange, onBlur }: ReportEditorProps) {
const editor = useEditor({
extensions: [
StarterKit,
Link.configure({
openOnClick: false,
}),
Placeholder.configure({
placeholder: 'Start writing your report...',
}),
],
content,
onUpdate: ({ editor: e }) => {
onChange(e.getHTML());
},
onBlur: () => {
onBlur?.();
},
});
useEffect(() => {
if (editor && content !== editor.getHTML()) {
editor.commands.setContent(content);
}
}, [content, editor]);
if (!editor) {
return null;
}
return (
<div className="flex h-full flex-col">
{/* Toolbar */}
<div className="mb-4 flex flex-wrap gap-1 border-b pb-2">
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
active={editor.isActive('heading', { level: 1 })}
label="Heading 1"
>
<Heading1 className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
active={editor.isActive('heading', { level: 2 })}
label="Heading 2"
>
<Heading2 className="h-4 w-4" />
</ToolbarButton>
<div className="mx-1 w-px bg-border" />
<ToolbarButton
onClick={() => editor.chain().focus().toggleBold().run()}
active={editor.isActive('bold')}
label="Bold"
>
<Bold className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleItalic().run()}
active={editor.isActive('italic')}
label="Italic"
>
<Italic className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleStrike().run()}
active={editor.isActive('strike')}
label="Strikethrough"
>
<Strikethrough className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleCode().run()}
active={editor.isActive('code')}
label="Code"
>
<Code className="h-4 w-4" />
</ToolbarButton>
<div className="mx-1 w-px bg-border" />
<ToolbarButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
active={editor.isActive('bulletList')}
label="Bullet list"
>
<List className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
active={editor.isActive('orderedList')}
label="Ordered list"
>
<ListOrdered className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
active={editor.isActive('blockquote')}
label="Blockquote"
>
<Quote className="h-4 w-4" />
</ToolbarButton>
<div className="mx-1 w-px bg-border" />
<ToolbarButton
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
label="Undo"
>
<Undo className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
label="Redo"
>
<Redo className="h-4 w-4" />
</ToolbarButton>
</div>
{/* Editor content */}
<div className="flex-1 overflow-auto">
<EditorContent editor={editor} className="tiptap-editor min-h-[400px]" />
</div>
</div>
);
}
@@ -0,0 +1,284 @@
'use client';
import { Calendar, Target, TrendingUp, Clock, FileBarChart } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
interface Template {
id: string;
name: string;
description: string;
type: 'weekly' | 'monthly' | 'project' | 'habit' | 'custom';
icon: React.ComponentType<{ className?: string }>;
content: string;
}
const templates: Template[] = [
{
id: 'weekly',
name: 'Weekly Summary',
description: 'Review your tasks, habits, and time from the past week',
type: 'weekly',
icon: Calendar,
content: `
<h1>Weekly Summary</h1>
<h2>Overview</h2>
<p>Week of [DATE_RANGE]</p>
<h2>Tasks Completed</h2>
<ul>
<li>[TASK_LIST]</li>
</ul>
<h2>Habits Tracked</h2>
<ul>
<li>[HABIT_SUMMARY]</li>
</ul>
<h2>Time Logged</h2>
<p>Total: [TIME_TOTAL]</p>
<h2>Reflections</h2>
<p>What went well this week?</p>
<p>What could be improved?</p>
<p>Goals for next week:</p>
`,
},
{
id: 'monthly',
name: 'Monthly Review',
description: 'Comprehensive review of the past month',
type: 'monthly',
icon: Calendar,
content: `
<h1>Monthly Review</h1>
<h2>Month of [DATE_RANGE]</h2>
<h2>Key Achievements</h2>
<ul>
<li>[ACHIEVEMENTS]</li>
</ul>
<h2>Project Progress</h2>
<ul>
<li>[PROJECT_SUMMARY]</li>
</ul>
<h2>Habit Consistency</h2>
<p>[HABIT_ANALYSIS]</p>
<h2>Time Distribution</h2>
<p>[TIME_BREAKDOWN]</p>
<h2>Lessons Learned</h2>
<p>What worked well?</p>
<p>What didn't work?</p>
<h2>Next Month's Focus</h2>
<p>Priorities:</p>
<p>Goals:</p>
`,
},
{
id: 'project',
name: 'Project Health',
description: 'Status and progress report for a specific project',
type: 'project',
icon: Target,
content: `
<h1>Project Health Report</h1>
<h2>[PROJECT_NAME]</h2>
<h2>Status Overview</h2>
<p>Progress: [PROGRESS]%</p>
<p>Tasks: [COMPLETED] / [TOTAL]</p>
<p>Due: [DUE_DATE]</p>
<h2>Milestones</h2>
<ul>
<li>[MILESTONE_LIST]</li>
</ul>
<h2>Recent Activity</h2>
<ul>
<li>[RECENT_TASKS]</li>
</ul>
<h2>Risks & Blockers</h2>
<p>[RISKS]</p>
<h2>Next Steps</h2>
<ul>
<li>[NEXT_STEPS]</li>
</ul>
`,
},
{
id: 'habit',
name: 'Habit Analysis',
description: 'Deep dive into habit performance and trends',
type: 'habit',
icon: TrendingUp,
content: `
<h1>Habit Analysis</h1>
<h2>Period: [DATE_RANGE]</h2>
<h2>Overall Performance</h2>
<p>Completion rate: [COMPLETION_RATE]%</p>
<p>Active streaks: [STREAK_COUNT]</p>
<h2>Top Performing Habits</h2>
<ol>
<li>[TOP_HABITS]</li>
</ol>
<h2>Habits Needing Attention</h2>
<ul>
<li>[AT_RISK_HABITS]</li>
</ul>
<h2>Trends & Patterns</h2>
<p>[TREND_ANALYSIS]</p>
<h2>Recommendations</h2>
<ul>
<li>[RECOMMENDATIONS]</li>
</ul>
`,
},
{
id: 'time',
name: 'Time Audit',
description: 'Breakdown of where your time went',
type: 'custom',
icon: Clock,
content: `
<h1>Time Audit</h1>
<h2>Period: [DATE_RANGE]</h2>
<h2>Total Time Tracked</h2>
<p>[TOTAL_TIME]</p>
<h2>By Domain</h2>
<ul>
<li>[DOMAIN_BREAKDOWN]</li>
</ul>
<h2>By Project</h2>
<ul>
<li>[PROJECT_BREAKDOWN]</li>
</ul>
<h2>By Category</h2>
<ul>
<li>[CATEGORY_BREAKDOWN]</li>
</ul>
<h2>Insights</h2>
<p>Where did most time go?</p>
<p>Was time aligned with priorities?</p>
<p>Adjustments for next period:</p>
`,
},
];
interface ReportTemplatesProps {
onSelect: (template: Template) => void;
onCancel: () => void;
}
export function ReportTemplates({ onSelect, onCancel }: ReportTemplatesProps) {
return (
<div>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Choose a Template</h1>
<p className="mt-1 text-muted-foreground">
Start with a template or create from scratch
</p>
</div>
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3" role="list">
{templates.map((template) => (
<Card
key={template.id}
className="cursor-pointer transition-shadow hover:shadow-md"
onClick={() => onSelect(template)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect(template);
}
}}
tabIndex={0}
role="listitem"
aria-label={`Use template: ${template.name}`}
>
<CardHeader className="pb-3">
<div className="flex items-start gap-3">
<div className="rounded-lg bg-primary/10 p-2">
<template.icon className="h-5 w-5 text-primary" />
</div>
<div className="flex-1">
<CardTitle className="text-base">{template.name}</CardTitle>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{template.description}</p>
</CardContent>
</Card>
))}
{/* Custom template */}
<Card
className="cursor-pointer border-dashed transition-shadow hover:shadow-md"
onClick={() =>
onSelect({
id: 'custom',
name: 'Custom Report',
description: 'Start with a blank report',
type: 'custom',
icon: FileBarChart,
content: '<h1>Custom Report</h1><p>Start writing...</p>',
})
}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect({
id: 'custom',
name: 'Custom Report',
description: 'Start with a blank report',
type: 'custom',
icon: FileBarChart,
content: '<h1>Custom Report</h1><p>Start writing...</p>',
});
}
}}
tabIndex={0}
role="listitem"
aria-label="Start with a blank custom report"
>
<CardHeader className="pb-3">
<div className="flex items-start gap-3">
<div className="rounded-lg bg-muted p-2">
<FileBarChart className="h-5 w-5 text-muted-foreground" />
</div>
<div className="flex-1">
<CardTitle className="text-base">Custom Report</CardTitle>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">Start with a blank report</p>
</CardContent>
</Card>
</div>
</div>
);
}
@@ -0,0 +1,189 @@
'use client';
import { useEffect, useState } from 'react';
import { Plus, Copy, Trash2 } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
interface Agent {
id: string;
name: string;
api_key: string;
permission_tier: string;
status: 'active' | 'disabled';
}
export function SettingsAgents() {
const [agents, setAgents] = useState<Agent[]>([]);
const [loading, setLoading] = useState(true);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [newAgentName, setNewAgentName] = useState('');
const [newAgentTier, setNewAgentTier] = useState('read_only');
useEffect(() => {
fetchAgents();
}, []);
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 createAgent() {
if (!newAgentName.trim()) return;
try {
await fetch('/api/agents', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: newAgentName,
permission_tier: newAgentTier,
status: 'active',
}),
});
setNewAgentName('');
setCreateDialogOpen(false);
fetchAgents();
} catch (error) {
console.error('Failed to create agent:', error);
}
}
async function deleteAgent(id: string) {
if (!confirm('Are you sure? This will revoke the agent\'s access.')) return;
try {
await fetch(`/api/agents/${id}`, { method: 'DELETE' });
fetchAgents();
} catch (error) {
console.error('Failed to delete agent:', error);
}
}
function copyApiKey(apiKey: string) {
navigator.clipboard.writeText(apiKey);
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Agents & Permissions</CardTitle>
<CardDescription>Manage AI agents and their access levels.</CardDescription>
</div>
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-1 h-4 w-4" />
New agent
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Agent</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="agent-name">Name</Label>
<Input
id="agent-name"
value={newAgentName}
onChange={(e) => setNewAgentName(e.target.value)}
placeholder="e.g., Hermes, Claude"
/>
</div>
<div className="space-y-2">
<Label htmlFor="agent-tier">Permission tier</Label>
<Select value={newAgentTier} onValueChange={setNewAgentTier}>
<SelectTrigger id="agent-tier">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="full_access">Full Access</SelectItem>
<SelectItem value="read_only">Read Only</SelectItem>
<SelectItem value="content_creator">Content Creator</SelectItem>
<SelectItem value="task_manager">Task Manager</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
</div>
<Button onClick={createAgent} className="w-full">
Create agent
</Button>
</div>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent>
{loading ? (
<p className="py-8 text-center text-sm text-muted-foreground">
Loading agents...
</p>
) : agents.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No agents configured
</p>
) : (
<div className="space-y-3">
{agents.map((agent) => (
<div key={agent.id} className="rounded-lg border p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="font-semibold">{agent.name}</h3>
<Badge variant={agent.status === 'active' ? 'default' : 'secondary'}>
{agent.status}
</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">
{agent.permission_tier.replace('_', ' ')}
</p>
<div className="mt-2 flex items-center gap-2">
<code className="rounded bg-muted px-2 py-1 text-xs">
{agent.api_key.slice(0, 8)}...
</code>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => copyApiKey(agent.api_key)}
aria-label={`Copy API key for ${agent.name}`}
>
<Copy className="h-3 w-3" aria-hidden="true" />
</Button>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => deleteAgent(agent.id)}
aria-label={`Delete agent: ${agent.name}`}
>
<Trash2 className="h-4 w-4 text-destructive" aria-hidden="true" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,105 @@
'use client';
import { useThemeStore } from '@/lib/stores/use-theme-store';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { cn } from '@/lib/utils';
import { ACCENT_COLORS, FONTS, DENSITIES, THEME_MODES } from '@/lib/theme';
export function SettingsAppearance() {
const { mode, accent, font, density, reducedMotion, setMode, setAccent, setFont, setDensity, setReducedMotion } = useThemeStore();
return (
<Card>
<CardHeader>
<CardTitle>Appearance</CardTitle>
<CardDescription>Customize how Project E looks and feels.</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Theme mode */}
<div className="space-y-2">
<Label htmlFor="theme-mode">Theme</Label>
<Select value={mode} onValueChange={(v) => setMode(v as 'light' | 'dark' | 'system')}>
<SelectTrigger id="theme-mode">
<SelectValue />
</SelectTrigger>
<SelectContent>
{THEME_MODES.map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Accent color */}
<div className="space-y-2">
<Label>Accent color</Label>
<div className="flex flex-wrap gap-2" role="radiogroup" aria-label="Accent color">
{ACCENT_COLORS.map((color) => (
<button
key={color.value}
onClick={() => setAccent(color.value)}
className={cn(
'h-8 w-8 rounded-full border-2 transition-all',
accent === color.value ? 'border-foreground scale-110' : 'border-transparent'
)}
style={{ backgroundColor: color.value }}
title={color.name}
aria-label={`${color.name} accent color`}
role="radio"
aria-checked={accent === color.value}
/>
))}
</div>
</div>
{/* Font */}
<div className="space-y-2">
<Label htmlFor="settings-font">Font</Label>
<Select value={font} onValueChange={setFont}>
<SelectTrigger id="settings-font">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FONTS.map((f) => (
<SelectItem key={f.value} value={f.value}>
{f.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Density */}
<div className="space-y-2">
<Label htmlFor="settings-density">Density</Label>
<Select value={density} onValueChange={(v) => setDensity(v as 'compact' | 'comfortable' | 'spacious')}>
<SelectTrigger id="settings-density">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DENSITIES.map((d) => (
<SelectItem key={d.value} value={d.value}>
{d.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Reduced motion */}
<div className="flex items-center justify-between">
<div>
<Label>Reduced motion</Label>
<p className="text-sm text-muted-foreground">Minimize animations and transitions</p>
</div>
<Switch checked={reducedMotion} onCheckedChange={setReducedMotion} aria-label="Reduced motion" />
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,126 @@
'use client';
import { useEffect, useState } from 'react';
import { Plus, Trash2 } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
interface Domain {
id: string;
name: string;
color: string;
icon: string;
sort_order: number;
}
export function SettingsDomains() {
const [domains, setDomains] = useState<Domain[]>([]);
const [newDomainName, setNewDomainName] = useState('');
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchDomains();
}, []);
async function fetchDomains() {
try {
const response = await fetch('/api/domains?sort=sort_order');
if (response.ok) {
const data = await response.json();
setDomains(data.items || []);
}
} catch (error) {
console.error('Failed to fetch domains:', error);
} finally {
setLoading(false);
}
}
async function addDomain() {
if (!newDomainName.trim()) return;
try {
await fetch('/api/domains', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: newDomainName,
color: '#3b82f6',
icon: '📁',
sort_order: domains.length,
}),
});
setNewDomainName('');
fetchDomains();
} catch (error) {
console.error('Failed to add domain:', error);
}
}
async function deleteDomain(id: string) {
if (!confirm('Are you sure? This cannot be undone.')) return;
try {
await fetch(`/api/domains/${id}`, { method: 'DELETE' });
fetchDomains();
} catch (error) {
console.error('Failed to delete domain:', error);
}
}
return (
<Card>
<CardHeader>
<CardTitle>Domains</CardTitle>
<CardDescription>Manage your workspace domains (e.g., Personal, Work, OTS).</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Existing domains */}
<div className="space-y-2">
{loading ? (
<p className="text-sm text-muted-foreground">Loading domains...</p>
) : (
domains.map((domain) => (
<div key={domain.id} className="flex items-center justify-between rounded-lg border p-3">
<div className="flex items-center gap-3">
<div
className="h-4 w-4 rounded"
style={{ backgroundColor: domain.color }}
/>
<span className="font-medium">{domain.name}</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => deleteDomain(domain.id)}
aria-label={`Delete domain: ${domain.name}`}
>
<Trash2 className="h-4 w-4 text-destructive" aria-hidden="true" />
</Button>
</div>
))
)}
</div>
{/* Add new domain */}
<div className="flex gap-2">
<label htmlFor="new-domain-name" className="sr-only">
New domain name
</label>
<Input
id="new-domain-name"
placeholder="New domain name"
value={newDomainName}
onChange={(e) => setNewDomainName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addDomain()}
/>
<Button onClick={addDomain}>
<Plus className="mr-1 h-4 w-4" />
Add
</Button>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,386 @@
'use client';
import { useState, useEffect } from 'react';
import { Download, Upload, Check, AlertCircle, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Progress } from '@/components/ui/progress';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
// ── Types ──────────────────────────────────────────────────────────────────
interface CollectionInfo {
name: string;
label: string;
}
interface ImportResultItem {
collection: string;
imported: number;
failed: number;
errors: string[];
}
interface ImportResult {
success: boolean;
imported: number;
failed: number;
results: ImportResultItem[];
}
const DEFAULT_COLLECTIONS: CollectionInfo[] = [
{ name: 'tasks', label: 'Tasks' },
{ name: 'habits', label: 'Habits' },
{ name: 'projects', label: 'Projects' },
{ name: 'notes', label: 'Notes' },
{ name: 'reports', label: 'Reports' },
{ name: 'milestones', label: 'Milestones' },
{ name: 'domains', label: 'Domains' },
{ name: 'tags', label: 'Tags' },
{ name: 'agents', label: 'Agents' },
{ name: 'webhooks', label: 'Webhooks' },
];
// ── Main Component ─────────────────────────────────────────────────────────
export function SettingsImportExport() {
const [exporting, setExporting] = useState(false);
const [importing, setImporting] = useState(false);
const [exportProgress, setExportProgress] = useState(0);
const [importProgress, setImportProgress] = useState(0);
const [selectedCollections, setSelectedCollections] = useState<string[]>(
DEFAULT_COLLECTIONS.map((c) => c.name)
);
const [importResult, setImportResult] = useState<ImportResult | null>(null);
const [confirmImport, setConfirmImport] = useState(false);
const [pendingImportFile, setPendingImportFile] = useState<File | null>(null);
// ── Export ────────────────────────────────────────────────────────────────
async function handleExport() {
setExporting(true);
setExportProgress(0);
try {
// Simulate progress while fetching
const progressInterval = setInterval(() => {
setExportProgress((prev) => Math.min(prev + 10, 90));
}, 200);
const response = await fetch('/api/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ collections: selectedCollections }),
});
clearInterval(progressInterval);
if (!response.ok) {
throw new Error('Export failed');
}
const data = await response.json();
setExportProgress(100);
// Download as JSON
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: 'application/json',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `project-e-export-${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
toast.success('Export completed successfully');
} catch (error) {
console.error('Failed to export:', error);
toast.error('Failed to export data');
} finally {
setExporting(false);
setExportProgress(0);
}
}
// ── Import ────────────────────────────────────────────────────────────────
function handleFileSelect(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
// Reset the input so the same file can be selected again
event.target.value = '';
if (!file.name.endsWith('.json')) {
toast.error('Please select a JSON file');
return;
}
setPendingImportFile(file);
setImportResult(null);
setConfirmImport(true);
}
async function executeImport() {
if (!pendingImportFile) return;
setConfirmImport(false);
setImporting(true);
setImportProgress(0);
try {
const text = await pendingImportFile.text();
const data = JSON.parse(text);
if (!data.version) {
toast.error('Invalid file — missing version field. Is this a valid Project E export?');
return;
}
// Simulate progress
const progressInterval = setInterval(() => {
setImportProgress((prev) => Math.min(prev + 5, 90));
}, 300);
const response = await fetch('/api/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
clearInterval(progressInterval);
if (!response.ok) {
const errorData = await response.json();
toast.error(errorData.error?.message || 'Import failed');
return;
}
const result: ImportResult = await response.json();
setImportProgress(100);
setImportResult(result);
if (result.success) {
toast.success(`Import complete: ${result.imported} records imported`);
} else {
toast.warning(
`Import finished with errors: ${result.imported} imported, ${result.failed} failed`
);
}
} catch (error) {
console.error('Failed to import:', error);
toast.error('Failed to parse import file. Please check the format.');
} finally {
setImporting(false);
setPendingImportFile(null);
}
}
// ── Collection toggle ─────────────────────────────────────────────────────
function toggleCollection(name: string) {
setSelectedCollections((prev) =>
prev.includes(name) ? prev.filter((c) => c !== name) : [...prev, name]
);
}
function toggleAllCollections() {
if (selectedCollections.length === DEFAULT_COLLECTIONS.length) {
setSelectedCollections([]);
} else {
setSelectedCollections(DEFAULT_COLLECTIONS.map((c) => c.name));
}
}
// ── Render ────────────────────────────────────────────────────────────────
return (
<Card>
<CardHeader>
<CardTitle>Import & Export</CardTitle>
<CardDescription>Backup your data or restore from a previous export.</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* ── Export Section ─────────────────────────────────────────────────── */}
<div className="rounded-lg border p-4">
<h3 className="font-semibold">Export data</h3>
<p className="mt-1 text-sm text-muted-foreground">
Download your data as a JSON file. Choose which collections to include.
</p>
{/* Collection selection */}
<div className="mt-4 space-y-2">
<div className="flex items-center gap-2">
<Checkbox
id="select-all"
checked={selectedCollections.length === DEFAULT_COLLECTIONS.length}
onCheckedChange={toggleAllCollections}
/>
<Label htmlFor="select-all" className="text-sm font-medium">
Select all
</Label>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-5">
{DEFAULT_COLLECTIONS.map((collection) => (
<div key={collection.name} className="flex items-center gap-2">
<Checkbox
id={`export-${collection.name}`}
checked={selectedCollections.includes(collection.name)}
onCheckedChange={() => toggleCollection(collection.name)}
/>
<Label
htmlFor={`export-${collection.name}`}
className="text-sm text-muted-foreground"
>
{collection.label}
</Label>
</div>
))}
</div>
</div>
{/* Progress */}
{exporting && (
<div className="mt-4 space-y-2">
<Progress value={exportProgress} className="h-2" />
<p className="text-xs text-muted-foreground">Exporting... {exportProgress}%</p>
</div>
)}
<Button
onClick={handleExport}
disabled={exporting || selectedCollections.length === 0}
className="mt-4"
>
{exporting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
{exporting ? 'Exporting...' : 'Export to JSON'}
</Button>
</div>
{/* ── Import Section ─────────────────────────────────────────────────── */}
<div className="rounded-lg border p-4">
<h3 className="font-semibold">Import data</h3>
<p className="mt-1 text-sm text-muted-foreground">
Restore from a previously exported JSON file. All existing data will be supplemented.
</p>
{/* Progress */}
{importing && (
<div className="mt-4 space-y-2">
<Progress value={importProgress} className="h-2" />
<p className="text-xs text-muted-foreground">Importing... {importProgress}%</p>
</div>
)}
{/* Import results */}
{importResult && (
<div className="mt-4 rounded-lg border p-3">
<div className="flex items-center gap-2">
{importResult.success ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<AlertCircle className="h-4 w-4 text-yellow-500" />
)}
<span className="text-sm font-medium">
{importResult.imported} imported, {importResult.failed} failed
</span>
</div>
{importResult.results.length > 0 && (
<div className="mt-3 space-y-2">
{importResult.results.map((r) => (
<div key={r.collection} className="flex items-center justify-between text-sm">
<span className="capitalize text-muted-foreground">{r.collection}</span>
<span>
{r.imported} ok
{r.failed > 0 && (
<span className="text-destructive">, {r.failed} failed</span>
)}
</span>
</div>
))}
</div>
)}
{importResult.results.some((r) => r.errors.length > 0) && (
<div className="mt-3">
<p className="text-xs font-medium text-destructive">Errors:</p>
<div className="mt-1 max-h-32 overflow-auto" role="list" aria-label="Import errors">
{importResult.results
.flatMap((r) => r.errors)
.slice(0, 10)
.map((error, i) => (
<p key={i} className="text-xs text-muted-foreground">
{error}
</p>
))}
</div>
</div>
)}
</div>
)}
<label className="mt-4 inline-block">
<input
type="file"
accept=".json"
onChange={handleFileSelect}
className="hidden"
disabled={importing}
/>
<Button variant="outline" disabled={importing} asChild>
<span>
{importing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
{importing ? 'Importing...' : 'Import from JSON'}
</span>
</Button>
</label>
</div>
{/* ── Import Confirmation Dialog ─────────────────────────────────────── */}
<AlertDialog open={confirmImport} onOpenChange={setConfirmImport}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm import</AlertDialogTitle>
<AlertDialogDescription>
This will import data from the selected file. Existing records will not be
overwritten, but new records will be created for each item in the file. Are you sure
you want to proceed?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setPendingImportFile(null)}>
Cancel
</AlertDialogCancel>
<AlertDialogAction onClick={executeImport}>
<Upload className="mr-2 h-4 w-4" />
Import
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
);
}

Some files were not shown because too many files have changed in this diff Show More