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