From 4c1cc502316d1643bbbb7c377a0a9947a02beba8 Mon Sep 17 00:00:00 2001 From: Matt Batchelder Date: Sat, 18 Jul 2026 19:05:52 -0400 Subject: [PATCH] feat: update ProjectE application --- Dockerfile.pocketbase | 2 +- apps/web/app/(auth)/login/page.tsx | 10 + apps/web/app/(dashboard)/agents/page.tsx | 135 ++++++++--- apps/web/app/(dashboard)/analytics/page.tsx | 129 +++++----- apps/web/app/(dashboard)/calendar/page.tsx | 74 +++--- apps/web/app/(dashboard)/dashboard/page.tsx | 58 ++++- apps/web/app/(dashboard)/habits/page.tsx | 57 ++++- apps/web/app/(dashboard)/notes/page.tsx | 224 ++++++++++++------ .../app/(dashboard)/projects/[id]/page.tsx | 3 +- apps/web/app/(dashboard)/projects/page.tsx | 45 +++- apps/web/app/(dashboard)/reports/page.tsx | 165 +++++++++---- .../(dashboard)/settings/error-log/page.tsx | 66 +++++- apps/web/app/(dashboard)/settings/page.tsx | 20 +- apps/web/app/(dashboard)/tasks/page.tsx | 34 ++- apps/web/app/api/time-summary/route.ts | 9 +- .../components/analytics/analytics-charts.tsx | 137 +++++------ .../calendar/big-calendar-wrapper.tsx | 12 +- apps/web/components/create-item-dialog.tsx | 123 ++++++++++ .../dashboard/responsive-grid-layout.tsx | 58 ++--- .../widgets/project-progress-widget.tsx | 2 +- .../dashboard/widgets/quick-add-widget.tsx | 13 +- apps/web/components/habits/habit-card.tsx | 2 +- .../habits/habit-completion-dialog.tsx | 2 +- apps/web/components/habits/habit-heatmap.tsx | 58 +++-- .../components/settings/settings-agents.tsx | 78 +++++- .../components/settings/settings-domains.tsx | 83 ++++++- .../settings/settings-import-export.tsx | 42 +++- apps/web/components/shortcuts-help.tsx | 5 +- apps/web/components/sidebar.tsx | 171 +++++++------ .../components/tasks/task-detail-panel.tsx | 67 +++++- .../components/tasks/tasks-kanban-view.tsx | 124 +++++++--- apps/web/components/tasks/tasks-list-view.tsx | 170 +++++++------ apps/web/components/topbar.tsx | 22 +- apps/web/hooks/use-keyboard-shortcuts.ts | 12 +- apps/web/middleware.ts | 6 +- apps/web/tsconfig.tsbuildinfo | 2 +- package-lock.json | 1 - .../1700000000_create_domains.js | 2 +- .../pb_migrations/1700000001_create_tags.js | 2 +- .../1700000002_create_projects.js | 2 +- .../1700000003_create_project_settings.js | 2 +- .../1700000004_create_milestones.js | 2 +- ...700000005_create_milestone_dependencies.js | 2 +- .../1700000006_create_milestone_templates.js | 2 +- .../1700000007_create_milestone_history.js | 2 +- .../pb_migrations/1700000008_create_tasks.js | 2 +- .../1700000009_create_task_subtasks.js | 2 +- .../1700000010_create_task_dependencies.js | 2 +- .../1700000011_create_task_attachments.js | 2 +- .../1700000012_create_task_time_entries.js | 2 +- .../pb_migrations/1700000013_create_habits.js | 2 +- .../1700000014_create_habit_logs.js | 2 +- .../1700000015_create_habit_skip_days.js | 2 +- .../pb_migrations/1700000016_create_notes.js | 2 +- .../1700000017_create_note_links.js | 2 +- .../1700000018_create_note_task_links.js | 2 +- .../1700000019_create_report_templates.js | 2 +- .../1700000020_create_reports.js | 2 +- .../1700000021_create_canvases.js | 2 +- .../1700000022_create_canvas_cards.js | 2 +- .../pb_migrations/1700000023_create_agents.js | 2 +- .../1700000024_create_agent_activity.js | 2 +- .../1700000025_create_webhooks.js | 2 +- .../1700000026_create_webhook_deliveries.js | 2 +- .../1700000027_create_agent_tasks.js | 2 +- .../1700000028_create_notifications.js | 2 +- .../1700000029_create_error_logs.js | 2 +- .../1700000030_create_queue_jobs.js | 2 +- 68 files changed, 1586 insertions(+), 697 deletions(-) create mode 100644 apps/web/components/create-item-dialog.tsx diff --git a/Dockerfile.pocketbase b/Dockerfile.pocketbase index 4b227eb..ce9357a 100644 --- a/Dockerfile.pocketbase +++ b/Dockerfile.pocketbase @@ -17,4 +17,4 @@ EXPOSE 8090 HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=10s \ CMD wget --no-verbose --tries=1 --spider http://localhost:8090/api/health || exit 1 -ENTRYPOINT ["/usr/local/bin/pocketbase", "serve", "--http=0.0.0.0:8090", "--dir=/pb_data", "--publicDir=/pb_public", "--migrationDir=/pb_migrations"] +ENTRYPOINT ["/usr/local/bin/pocketbase", "serve", "--http=0.0.0.0:8090", "--dir=/pb_data", "--publicDir=/pb_public", "--migrationsDir=/pb_migrations"] diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index 8ed6a04..10fcb42 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -13,9 +13,11 @@ export default function LoginPage() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [loading, setLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); + setErrorMessage(''); setLoading(true); try { @@ -32,6 +34,7 @@ export default function LoginPage() { router.push('/dashboard'); } catch (error) { + setErrorMessage(error instanceof Error ? error.message : 'Unable to sign in. Check your credentials and try again.'); handleApiError(error, 'Login failed'); } finally { setLoading(false); @@ -47,6 +50,11 @@ export default function LoginPage() {
+ {errorMessage && ( + + )}
setEmail(e.target.value)} required + aria-describedby={errorMessage ? 'login-error' : undefined} />
@@ -66,6 +75,7 @@ export default function LoginPage() { value={password} onChange={(e) => setPassword(e.target.value)} required + aria-describedby={errorMessage ? 'login-error' : undefined} />
diff --git a/apps/web/app/(dashboard)/agents/page.tsx b/apps/web/app/(dashboard)/agents/page.tsx index 73d1ecb..112b559 100644 --- a/apps/web/app/(dashboard)/agents/page.tsx +++ b/apps/web/app/(dashboard)/agents/page.tsx @@ -43,7 +43,14 @@ export default function AgentsPage() { const [agents, setAgents] = useState([]); const [activity, setActivity] = useState([]); const [agentTasks, setAgentTasks] = useState([]); - const [loading, setLoading] = useState(true); + const [agentsLoading, setAgentsLoading] = useState(true); + const [activityLoading, setActivityLoading] = useState(true); + const [agentTasksLoading, setAgentTasksLoading] = useState(true); + const [agentsError, setAgentsError] = useState(null); + const [activityError, setActivityError] = useState(null); + const [agentTasksError, setAgentTasksError] = useState(null); + const [undoingActivityId, setUndoingActivityId] = useState(null); + const [feedback, setFeedback] = useState<{ type: 'success' | 'error'; message: string } | null>(null); const [selectedAgent, setSelectedAgent] = useState(null); useEffect(() => { @@ -53,51 +60,76 @@ export default function AgentsPage() { }, []); async function fetchAgents() { + setAgentsLoading(true); + setAgentsError(null); try { const response = await fetch('/api/agents'); - if (response.ok) { - const data = await response.json(); - setAgents(data.items || []); + if (!response.ok) { + throw new Error('Unable to load agents.'); } + const data = await response.json(); + setAgents(data.items || []); } catch (error) { console.error('Failed to fetch agents:', error); + setAgentsError('Unable to load agents. Please try again.'); } finally { - setLoading(false); + setAgentsLoading(false); } } async function fetchActivity() { + setActivityLoading(true); + setActivityError(null); try { const response = await fetch('/api/agent-activity?sort=-created&perPage=50'); - if (response.ok) { - const data = await response.json(); - setActivity(data.items || []); + if (!response.ok) { + throw new Error('Unable to load agent activity.'); } + const data = await response.json(); + setActivity(data.items || []); } catch (error) { console.error('Failed to fetch activity:', error); + setActivityError('Unable to load agent activity. Please try again.'); + } finally { + setActivityLoading(false); } } async function fetchAgentTasks() { + setAgentTasksLoading(true); + setAgentTasksError(null); try { const response = await fetch('/api/agent-tasks?sort=-created&perPage=50'); - if (response.ok) { - const data = await response.json(); - setAgentTasks(data.items || []); + if (!response.ok) { + throw new Error('Unable to load agent tasks.'); } + const data = await response.json(); + setAgentTasks(data.items || []); } catch (error) { console.error('Failed to fetch agent tasks:', error); + setAgentTasksError('Unable to load agent tasks. Please try again.'); + } finally { + setAgentTasksLoading(false); } } async function undoActivity(activityId: string) { + setUndoingActivityId(activityId); + setFeedback(null); try { - await fetch(`/api/agent-activity/${activityId}/undo`, { + const response = await fetch(`/api/agent-activity/${activityId}/undo`, { method: 'POST', }); - fetchActivity(); + if (!response.ok) { + throw new Error('Unable to undo this activity.'); + } + setFeedback({ type: 'success', message: 'Activity undone successfully.' }); + await fetchActivity(); } catch (error) { console.error('Failed to undo activity:', error); + setFeedback({ type: 'error', message: 'Unable to undo this activity. Please try again.' }); + } finally { + setUndoingActivityId(null); } } @@ -130,10 +162,6 @@ export default function AgentsPage() { return labels[action] || action; } - if (loading) { - return

Loading agent activity...

; - } - return (
@@ -141,6 +169,16 @@ export default function AgentsPage() {

Every agent action, visible and reversible.

+ {feedback && ( +

+ {feedback.message} +

+ )}
@@ -150,10 +188,22 @@ export default function AgentsPage() { Agents - {agents.length === 0 ? ( -

- No agents configured -

+ {agentsLoading ? ( +

Loading agents...

+ ) : agentsError ? ( +
+

{agentsError}

+ +
+ ) : agents.length === 0 ? ( +
+

No agents configured

+ +
) : (
{agents.map((agent) => ( @@ -217,10 +267,22 @@ export default function AgentsPage() { - {activity.length === 0 ? ( -

- No agent activity yet -

+ {activityLoading ? ( +

Loading agent activity...

+ ) : activityError ? ( +
+

{activityError}

+ +
+ ) : activity.length === 0 ? ( +
+

No agent activity yet

+ +
) : (
{activity.map((item) => ( @@ -277,11 +339,12 @@ export default function AgentsPage() { variant="ghost" size="sm" onClick={() => undoActivity(item.id)} + disabled={undoingActivityId !== null} className="shrink-0" aria-label={`Undo activity: ${getActionLabel(item.action)} ${item.entity_type}`} >
@@ -291,10 +354,22 @@ export default function AgentsPage() { - {agentTasks.length === 0 ? ( -

- No agent tasks yet -

+ {agentTasksLoading ? ( +

Loading agent tasks...

+ ) : agentTasksError ? ( +
+

{agentTasksError}

+ +
+ ) : agentTasks.length === 0 ? ( +
+

No agent tasks yet

+ +
) : (
{agentTasks.map((task) => ( diff --git a/apps/web/app/(dashboard)/analytics/page.tsx b/apps/web/app/(dashboard)/analytics/page.tsx index d774b63..a6ebe8d 100644 --- a/apps/web/app/(dashboard)/analytics/page.tsx +++ b/apps/web/app/(dashboard)/analytics/page.tsx @@ -11,6 +11,7 @@ import { } from 'lucide-react'; import dynamic from 'next/dynamic'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; import { Tabs, TabsContent, @@ -65,88 +66,110 @@ interface HabitData { consistency: number; } +function countByDate(records: Array>, field: string) { + return records.reduce>((counts, record) => { + const value = record[field]; + if (typeof value === 'string' && !Number.isNaN(new Date(value).getTime())) { + const date = new Date(value).toISOString().slice(0, 10); + counts[date] = (counts[date] || 0) + 1; + } + return counts; + }, {}); +} + export default function AnalyticsPage() { const [analytics, setAnalytics] = useState(null); const [timeData, setTimeData] = useState([]); const [domainData, setDomainData] = useState([]); const [habitData, setHabitData] = useState([]); const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); useEffect(() => { fetchAnalytics(); }, []); async function fetchAnalytics() { + setLoading(true); + setError(null); try { - // Fetch overall analytics - const analyticsResponse = await fetch('/api/analytics?period=30'); - if (analyticsResponse.ok) { - const analyticsData = await analyticsResponse.json(); - setAnalytics(analyticsData); + const startDate = new Date(); + startDate.setDate(startDate.getDate() - 29); + const start = startDate.toISOString(); + const [analyticsResponse, timeResponse, habitsResponse, tasksResponse, habitLogsResponse] = await Promise.all([ + fetch('/api/analytics?period=30'), + fetch(`/api/time-summary?start=${encodeURIComponent(start)}`), + fetch('/api/habits/streaks'), + fetch('/api/tasks?perPage=500'), + fetch(`/api/habit-logs?start=${encodeURIComponent(start)}`), + ]); + + if (![analyticsResponse, timeResponse, habitsResponse, tasksResponse, habitLogsResponse].every((response) => response.ok)) { + throw new Error('One or more analytics sources could not be loaded.'); } - // Fetch time summary - const timeResponse = await fetch('/api/time-summary?period=30'); - if (timeResponse.ok) { - const timeSummary = await timeResponse.json(); + const [analyticsData, timeSummary, habitsData, tasksData, habitLogsData] = await Promise.all([ + analyticsResponse.json(), + timeResponse.json(), + habitsResponse.json(), + tasksResponse.json(), + habitLogsResponse.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)); + 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], + })); + const habits: HabitData[] = (habitsData.streaks || []).map( + (s: { habit: { name: string; score?: number }; current_streak: number }) => ({ + name: s.habit.name, + streak: s.current_streak, + score: s.habit.score || 0, + consistency: 0, + }) + ); + const completedByDate = countByDate(tasksData.items || [], 'completed_at'); + const habitsByDate = countByDate(habitLogsData.items || [], 'logged_at'); + const minutesByDate = timeSummary.byDate || {}; + const dailyData = Array.from({ length: 30 }, (_, index) => { + const date = new Date(startDate); + date.setDate(startDate.getDate() + index); + const key = date.toISOString().slice(0, 10); 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, + date: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + tasks: completedByDate[key] || 0, + habits: habitsByDate[key] || 0, + time: minutesByDate[key] || 0, }; }); - setTimeData(sampleTimeData); + + setAnalytics(analyticsData); + setDomainData(domains); + setHabitData(habits); + setTimeData(dailyData); } catch (error) { console.error('Failed to fetch analytics:', error); + setError('Analytics could not be loaded. Please try again.'); } finally { setLoading(false); } } - if (loading || !analytics) { + if (loading) { return

Loading analytics...

; } + if (error || !analytics) { + return ( +
+

{error || 'Analytics are unavailable.'}

+ +
+ ); + } + return (
diff --git a/apps/web/app/(dashboard)/calendar/page.tsx b/apps/web/app/(dashboard)/calendar/page.tsx index 527d600..cd98810 100644 --- a/apps/web/app/(dashboard)/calendar/page.tsx +++ b/apps/web/app/(dashboard)/calendar/page.tsx @@ -27,16 +27,17 @@ interface CalendarEvent { title: string; start: Date; end: Date; - type: 'task' | 'habit' | 'project' | 'milestone'; + type: 'task' | 'project' | 'milestone'; domain: string; color: string; + href: string; } export default function CalendarPage() { const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); const [showTasks, setShowTasks] = useState(true); - const [showHabits, setShowHabits] = useState(true); const [showProjects, setShowProjects] = useState(true); const [showMilestones, setShowMilestones] = useState(true); const [selectedDomains, setSelectedDomains] = useState([]); @@ -46,18 +47,24 @@ export default function CalendarPage() { }, []); async function fetchEvents() { + setLoading(true); + setError(null); 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: [] }; + const [tasksResponse, projectsResponse, milestonesResponse] = await Promise.all([ + fetch('/api/tasks?filter=due_date!%3D%22%22&perPage=500'), + fetch('/api/projects?filter=due_date!%3D%22%22&perPage=500'), + fetch('/api/milestones?filter=due_date!%3D%22%22&perPage=500'), + ]); - // 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: [] }; + if (!tasksResponse.ok || !projectsResponse.ok || !milestonesResponse.ok) { + throw new Error('One or more calendar sources could not be loaded.'); + } - // 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 [tasksData, projectsData, milestonesData] = await Promise.all([ + tasksResponse.json(), + projectsResponse.json(), + milestonesResponse.json(), + ]); const calendarEvents: CalendarEvent[] = []; @@ -73,7 +80,8 @@ export default function CalendarPage() { end: date, type: 'task', domain: task.domain ?? 'personal', - color: '#3b82f6', // blue + color: '#3b82f6', + href: '/tasks', }); } } @@ -82,16 +90,17 @@ export default function CalendarPage() { // Add projects if (projectsData.items) { for (const project of projectsData.items) { - if (project.target_date) { - const date = new Date(project.target_date); + if (project.due_date) { + const date = new Date(project.due_date); calendarEvents.push({ id: `project-${project.id}`, - title: `📁 ${project.name}`, + title: project.name, start: date, end: date, type: 'project', domain: project.domain ?? 'personal', - color: '#8b5cf6', // purple + color: '#8b5cf6', + href: `/projects/${project.id}`, }); } } @@ -100,16 +109,17 @@ export default function CalendarPage() { // Add milestones if (milestonesData.items) { for (const milestone of milestonesData.items) { - if (milestone.target_date) { - const date = new Date(milestone.target_date); + if (milestone.due_date) { + const date = new Date(milestone.due_date); calendarEvents.push({ id: `milestone-${milestone.id}`, - title: `🎯 ${milestone.name}`, + title: milestone.name || milestone.title, start: date, end: date, type: 'milestone', domain: milestone.domain ?? 'work', - color: '#f59e0b', // amber + color: '#f59e0b', + href: milestone.project_id ? `/projects/${milestone.project_id}` : '/projects', }); } } @@ -118,6 +128,7 @@ export default function CalendarPage() { setEvents(calendarEvents); } catch (error) { console.error('Failed to fetch calendar events:', error); + setError('Calendar events could not be loaded. Please try again.'); } finally { setLoading(false); } @@ -127,7 +138,6 @@ export default function CalendarPage() { 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; @@ -138,7 +148,7 @@ export default function CalendarPage() { return true; }); - }, [events, showTasks, showHabits, showProjects, showMilestones, selectedDomains]); + }, [events, showTasks, showProjects, showMilestones, selectedDomains]); function toggleDomain(domain: string) { setSelectedDomains((prev) => @@ -154,6 +164,15 @@ export default function CalendarPage() { ); } + if (error) { + return ( +
+

{error}

+ +
+ ); + } + return (
@@ -188,17 +207,6 @@ export default function CalendarPage() { Tasks
-
- setShowHabits(checked === true)} - /> -
- +
{/* Task count */} diff --git a/apps/web/app/(dashboard)/reports/page.tsx b/apps/web/app/(dashboard)/reports/page.tsx index 642fe31..098bd10 100644 --- a/apps/web/app/(dashboard)/reports/page.tsx +++ b/apps/web/app/(dashboard)/reports/page.tsx @@ -1,11 +1,23 @@ 'use client'; -import { useEffect, useState, Suspense } from 'react'; -import { Plus, FileBarChart, Calendar, Target, TrendingUp, Clock } from 'lucide-react'; +import { useEffect, useRef, useState, Suspense } from 'react'; +import { Plus, FileBarChart, Calendar, Target, TrendingUp, Clock, Trash2 } from 'lucide-react'; import dynamic from 'next/dynamic'; +import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; // Lazy load TipTap report editor (~80KB) const ReportEditor = dynamic( @@ -50,24 +62,34 @@ export default function ReportsPage() { const [selectedReport, setSelectedReport] = useState(null); const [loading, setLoading] = useState(true); const [showTemplates, setShowTemplates] = useState(false); + const [error, setError] = useState(null); + const [saveStatus, setSaveStatus] = useState<'Saved' | 'Saving' | 'Failed'>('Saved'); + const [reportToDelete, setReportToDelete] = useState(null); + const [deleting, setDeleting] = useState(false); + const pendingSave = useRef<{ id: string; updates: Partial } | null>(null); + const saveTimer = useRef | null>(null); + const saveVersion = useRef(0); useEffect(() => { fetchReports(); + return () => { + if (saveTimer.current) clearTimeout(saveTimer.current); + }; }, []); async function fetchReports() { + setLoading(true); + setError(null); 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]); - } - } + if (!response.ok) throw new Error('Unable to load reports.'); + const data = await response.json(); + const reportsList = data.items || []; + setReports(reportsList); + setSelectedReport((current) => current || reportsList[0] || null); } catch (error) { console.error('Failed to fetch reports:', error); + setError('Unable to load reports. Please try again.'); } finally { setLoading(false); } @@ -86,42 +108,73 @@ export default function ReportsPage() { ...overrides, }), }); - if (response.ok) { - const newReport = await response.json(); - setReports([newReport, ...reports]); - setSelectedReport(newReport); - setShowTemplates(false); - } + if (!response.ok) throw new Error('Unable to create report.'); + const newReport = await response.json(); + setReports((current) => [newReport, ...current]); + setSelectedReport(newReport); + setShowTemplates(false); + toast.success('Report created'); } catch (error) { console.error('Failed to create report:', error); + toast.error('Unable to create report'); } } - async function updateReport(reportId: string, updates: Partial) { + function scheduleSave(reportId: string, updates: Partial) { + const version = ++saveVersion.current; + pendingSave.current = { + id: reportId, + updates: { ...(pendingSave.current?.id === reportId ? pendingSave.current.updates : {}), ...updates }, + }; + if (saveTimer.current) clearTimeout(saveTimer.current); + setSaveStatus('Saving'); + saveTimer.current = setTimeout(() => { + const save = pendingSave.current; + pendingSave.current = null; + if (save) updateReport(save.id, save.updates, version); + }, 700); + } + + async function updateReport(reportId: string, updates: Partial, version: number) { try { - await fetch(`/api/reports/${reportId}`, { + const response = await fetch(`/api/reports/${reportId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates), }); - fetchReports(); + if (!response.ok) throw new Error('Unable to save report.'); + const updated = await response.json(); + setReports((current) => current.map((report) => (report.id === reportId ? updated : report))); + if (saveVersion.current === version) setSaveStatus('Saved'); } catch (error) { console.error('Failed to update report:', error); + if (saveVersion.current === version) setSaveStatus('Failed'); + toast.error('Unable to save report'); } } - async function deleteReport(reportId: string) { - if (!confirm('Are you sure you want to delete this report?')) return; - + async function deleteReport() { + if (!reportToDelete) return; + if (pendingSave.current?.id === reportToDelete.id && saveTimer.current) { + clearTimeout(saveTimer.current); + pendingSave.current = null; + } + ++saveVersion.current; + setDeleting(true); 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); - } + const response = await fetch(`/api/reports/${reportToDelete.id}`, { method: 'DELETE' }); + if (!response.ok) throw new Error('Unable to delete report.'); + setReports((current) => current.filter((report) => report.id !== reportToDelete.id)); + setSelectedReport((selected) => + selected?.id === reportToDelete.id ? reports.find((report) => report.id !== reportToDelete.id) || null : selected + ); + setReportToDelete(null); + toast.success('Report deleted'); } catch (error) { console.error('Failed to delete report:', error); + toast.error('Unable to delete report'); + } finally { + setDeleting(false); } } @@ -143,7 +196,11 @@ export default function ReportsPage() { } if (loading) { - return

Loading reports...

; + return

Loading reports...

; + } + + if (error) { + return

{error}

; } if (showTemplates) { @@ -171,7 +228,7 @@ export default function ReportsPage() { return (
-
+

Reports

Step back and see what changed.

@@ -189,12 +246,16 @@ export default function ReportsPage() {
{/* Reports list */} - +
{reports.length === 0 ? ( -

- No reports yet -

+
+

No reports yet

+ +
) : (
{reports.map((report) => ( @@ -236,26 +297,35 @@ export default function ReportsPage() { {/* Report editor */} - + {selectedReport ? (
- + - setSelectedReport({ ...selectedReport, title: e.target.value }) - } - onBlur={() => - updateReport(selectedReport.id, { title: selectedReport.title }) - } - className="w-full text-xl font-semibold outline-none" + onChange={(e) => { const title = e.target.value; setSelectedReport({ ...selectedReport, title }); scheduleSave(selectedReport.id, { title }); }} + className="min-w-0 flex-1 text-xl font-semibold outline-none" placeholder="Report title" - /> + /> + {saveStatus} + !open && setReportToDelete(null)}> + + + + + Delete {reportToDelete?.title}?This permanently deletes this report. + Cancel{deleting ? 'Deleting...' : 'Delete report'} + + +
{selectedReport.report_type} {selectedReport.domain} @@ -280,12 +350,7 @@ export default function ReportsPage() { > - setSelectedReport({ ...selectedReport, content }) - } - onBlur={() => - updateReport(selectedReport.id, { content: selectedReport.content }) - } + onChange={(content) => { setSelectedReport({ ...selectedReport, content }); scheduleSave(selectedReport.id, { content }); }} />
diff --git a/apps/web/app/(dashboard)/settings/error-log/page.tsx b/apps/web/app/(dashboard)/settings/error-log/page.tsx index b565eac..393d8b5 100644 --- a/apps/web/app/(dashboard)/settings/error-log/page.tsx +++ b/apps/web/app/(dashboard)/settings/error-log/page.tsx @@ -10,6 +10,16 @@ import { } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { AlertTriangle, Trash2 } from 'lucide-react'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; interface ErrorLog { id: string; @@ -23,33 +33,46 @@ interface ErrorLog { export default function ErrorLogPage() { const [errors, setErrors] = useState([]); const [loading, setLoading] = useState(true); + const [clearing, setClearing] = useState(false); + const [confirmClear, setConfirmClear] = useState(false); + const [error, setError] = useState(null); + const [status, setStatus] = useState(null); useEffect(() => { fetchErrors(); }, []); async function fetchErrors() { + setLoading(true); + setError(null); try { const response = await fetch('/api/error-logs?limit=50'); - if (response.ok) { - const data = await response.json(); - setErrors(data.items || []); - } + if (!response.ok) throw new Error('Unable to load error logs.'); + const data = await response.json(); + setErrors(data.items || []); } catch (error) { console.error('Failed to fetch error logs:', error); + setError('Unable to load error logs. Please try again.'); } finally { setLoading(false); } } async function clearErrors() { + setClearing(true); + setError(null); + setStatus(null); try { const response = await fetch('/api/error-logs', { method: 'DELETE' }); - if (response.ok) { - setErrors([]); - } + if (!response.ok) throw new Error('Unable to clear error logs.'); + setErrors([]); + setConfirmClear(false); + setStatus('Error logs cleared successfully.'); } catch (error) { console.error('Failed to clear error logs:', error); + setError('Unable to clear error logs. Please try again.'); + } finally { + setClearing(false); } } @@ -75,14 +98,35 @@ export default function ErrorLogPage() {
{errors.length > 0 && ( - + + + + + Clear all error logs? + This permanently removes all displayed error logs. + + + Cancel + + {clearing ? 'Clearing...' : 'Clear all'} + + + + )}
+ {error && ( +
+ {error} + +
+ )} + {status &&

{status}

} {errors.length === 0 ? (

No errors logged diff --git a/apps/web/app/(dashboard)/settings/page.tsx b/apps/web/app/(dashboard)/settings/page.tsx index ef1d30c..d763797 100644 --- a/apps/web/app/(dashboard)/settings/page.tsx +++ b/apps/web/app/(dashboard)/settings/page.tsx @@ -26,39 +26,39 @@ export default function SettingsPage() {

Tune Project E to fit your work.

- - - + + + - + - + - + - + - + - + -
+
diff --git a/apps/web/app/(dashboard)/tasks/page.tsx b/apps/web/app/(dashboard)/tasks/page.tsx index 47df604..1662103 100644 --- a/apps/web/app/(dashboard)/tasks/page.tsx +++ b/apps/web/app/(dashboard)/tasks/page.tsx @@ -1,14 +1,28 @@ 'use client'; -import { useState } from 'react'; -import { LayoutGrid, List } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { LayoutGrid, List, Plus } 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'; +import { CreateItemDialog } from '@/components/create-item-dialog'; export default function TasksPage() { const [view, setView] = useState<'kanban' | 'list'>('kanban'); + const [createOpen, setCreateOpen] = useState(false); + const [refreshKey, setRefreshKey] = useState(0); + + useEffect(() => { + if (new URLSearchParams(window.location.search).get('new') === 'true') setCreateOpen(true); + }, []); + + function handleCreateOpenChange(open: boolean) { + setCreateOpen(open); + if (!open && new URLSearchParams(window.location.search).get('new') === 'true') { + window.history.replaceState(null, '', '/tasks'); + } + } return (
@@ -20,6 +34,10 @@ export default function TasksPage() {

+ setView(v as 'kanban' | 'list')} @@ -38,7 +56,17 @@ export default function TasksPage() {
- {view === 'kanban' ? : } + {view === 'kanban' ? ( + + ) : ( + + )} + setRefreshKey((key) => key + 1)} + />
); } diff --git a/apps/web/app/api/time-summary/route.ts b/apps/web/app/api/time-summary/route.ts index d6ae853..d14e9b4 100644 --- a/apps/web/app/api/time-summary/route.ts +++ b/apps/web/app/api/time-summary/route.ts @@ -15,13 +15,19 @@ export const GET = withAuth(async (request: NextRequest, _user) => { }); const byDomain: Record = {}; + const byDate: Record = {}; const byProject: Record = {}; const byTag: Record = {}; let totalMinutes = 0; for (const entry of entries) { - const duration = (entry.duration_minutes as number) || 0; + const duration = (entry.duration_minutes as number) || (entry.duration as number) || 0; totalMinutes += duration; + const startedAt = entry.started_at as string | undefined; + if (startedAt) { + const date = new Date(startedAt).toISOString().slice(0, 10); + byDate[date] = (byDate[date] || 0) + duration; + } // Get task for domain/project/tags const task = await pb.collection('tasks').getOne(entry.task_id as string); @@ -43,6 +49,7 @@ export const GET = withAuth(async (request: NextRequest, _user) => { return NextResponse.json({ totalMinutes, byDomain, + byDate, byProject, byTag, startDate, diff --git a/apps/web/components/analytics/analytics-charts.tsx b/apps/web/components/analytics/analytics-charts.tsx index 408e447..e92f808 100644 --- a/apps/web/components/analytics/analytics-charts.tsx +++ b/apps/web/components/analytics/analytics-charts.tsx @@ -67,6 +67,13 @@ export function AnalyticsCharts({ habitData, activeTab, }: AnalyticsChartsProps) { + const hasActivity = timeData.some((day) => day.tasks > 0 || day.habits > 0 || day.time > 0); + const hasRecentActivity = timeData.slice(-7).some((day) => day.tasks > 0 || day.habits > 0); + const activitySummary = timeData + .filter((day) => day.tasks > 0 || day.habits > 0 || day.time > 0) + .map((day) => `${day.date}: ${day.tasks} tasks completed, ${day.habits} habits logged, ${day.time} minutes tracked.`) + .join(' '); + if (activeTab === 'trends') { return (
@@ -79,43 +86,23 @@ export function AnalyticsCharts({ - - - - - - - - - - - + {!hasActivity ? ( +

No completed tasks or habit logs in the last 30 days.

+ ) : ( +
+ + + +
+ )}
@@ -128,37 +115,21 @@ export function AnalyticsCharts({ - - - - - - - - - + {timeData.some((day) => day.time > 0) ? ( +
+ + + +
+ ) : ( +

No time tracked in the last 30 days.

+ )}
@@ -182,8 +153,9 @@ export function AnalyticsCharts({ No habits tracked

) : ( - - +
`${habit.name}, ${habit.streak} days`).join('; ')}.`}> + + - - + + +
)}
@@ -239,6 +212,11 @@ export function AnalyticsCharts({
@@ -269,6 +247,7 @@ export function AnalyticsCharts({

) : (
+

Time by domain: {domainData.map((domain) => `${domain.name}, ${domain.value} minutes`).join('; ')}.

- - + {!hasRecentActivity ? ( +

No task or habit activity in the last 7 days.

+ ) : ( +
+ + - - + + +
+ )}
diff --git a/apps/web/components/calendar/big-calendar-wrapper.tsx b/apps/web/components/calendar/big-calendar-wrapper.tsx index 519298c..8239486 100644 --- a/apps/web/components/calendar/big-calendar-wrapper.tsx +++ b/apps/web/components/calendar/big-calendar-wrapper.tsx @@ -4,6 +4,7 @@ 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'; +import { useRouter } from 'next/navigation'; const locales = { 'en-US': enUS, @@ -22,9 +23,10 @@ interface CalendarEvent { title: string; start: Date; end: Date; - type: 'task' | 'habit' | 'project' | 'milestone'; + type: 'task' | 'project' | 'milestone'; domain: string; color: string; + href: string; } interface BigCalendarWrapperProps { @@ -44,11 +46,9 @@ function eventStyleGetter(event: CalendarEvent) { }; } -function handleSelectEvent(event: CalendarEvent) { - console.log('Selected event:', event); -} - export function BigCalendarWrapper({ events }: BigCalendarWrapperProps) { + const router = useRouter(); + return ( router.push(event.href)} views={['month', 'week', 'day']} defaultView="month" popup diff --git a/apps/web/components/create-item-dialog.tsx b/apps/web/components/create-item-dialog.tsx new file mode 100644 index 0000000..c7c817a --- /dev/null +++ b/apps/web/components/create-item-dialog.tsx @@ -0,0 +1,123 @@ +'use client'; + +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +type ItemType = 'task' | 'project' | 'habit'; + +const labels = { + task: { title: 'New task', field: 'Task title' }, + project: { title: 'New project', field: 'Project name' }, + habit: { title: 'New habit', field: 'Habit name' }, +} as const; + +export function CreateItemDialog({ + type, + open, + onOpenChange, + onCreated, +}: { + type: ItemType; + open: boolean; + onOpenChange: (open: boolean) => void; + onCreated: () => void; +}) { + const [name, setName] = useState(''); + const [domain, setDomain] = useState('General'); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(''); + const copy = labels[type]; + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setSubmitting(true); + setError(''); + + const body = + type === 'task' + ? { title: name, domain, status: 'todo', priority: 'medium', tags: [] } + : type === 'project' + ? { name, domain, status: 'active', tags: [] } + : { + name, + domain, + frequency: 'daily', + difficulty: 'medium', + completion_mode: 'quick', + goal_per_period: 1, + active: true, + tags: [], + }; + + try { + const response = await fetch(`/api/${type === 'task' ? 'tasks' : `${type}s`}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error('Unable to create item'); + } + + setName(''); + onOpenChange(false); + onCreated(); + } catch { + setError(`Unable to create this ${type}. Please try again.`); + } finally { + setSubmitting(false); + } + } + + return ( + + + + {copy.title} + Give it a name and choose where it belongs. + + +
+ + setName(event.target.value)} + autoFocus + required + /> +
+
+ + setDomain(event.target.value)} + required + /> +
+ {error &&

{error}

} + + + + + +
+
+ ); +} diff --git a/apps/web/components/dashboard/responsive-grid-layout.tsx b/apps/web/components/dashboard/responsive-grid-layout.tsx index 50cb938..864e4cc 100644 --- a/apps/web/components/dashboard/responsive-grid-layout.tsx +++ b/apps/web/components/dashboard/responsive-grid-layout.tsx @@ -1,30 +1,13 @@ 'use client'; -import ReactGridLayout from 'react-grid-layout'; +import { ResponsiveGridLayout, useContainerWidth, verticalCompactor } from 'react-grid-layout'; +import type { Layout } 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:

>>( - component: P - ) => React.ComponentType & { measureBeforeMount?: boolean }>; - } -).WidthProvider; - -const Responsive = ( - ReactGridLayout as unknown as { - Responsive: React.ComponentType; - } -).Responsive; - -const ResponsiveGridLayout = WidthProvider(Responsive); - interface ResponsiveGridProps { - layout: ReactGridLayout.Layout[]; - onLayoutChange: (newLayout: ReactGridLayout.Layout[]) => void; + layout: Layout; + onLayoutChange: (newLayout: Layout) => void; children: React.ReactNode; } @@ -33,19 +16,26 @@ export default function ResponsiveGrid({ onLayoutChange, children, }: ResponsiveGridProps) { + const { width, containerRef, mounted } = useContainerWidth(); + return ( - - {children} - +

+ {mounted && ( + onLayoutChange(_layout)} + dragConfig={{ handle: '.widget-drag-handle' }} + compactor={verticalCompactor} + resizeConfig={{ enabled: true }} + > + {children} + + )} +
); } diff --git a/apps/web/components/dashboard/widgets/project-progress-widget.tsx b/apps/web/components/dashboard/widgets/project-progress-widget.tsx index fa0de3c..dc6e78b 100644 --- a/apps/web/components/dashboard/widgets/project-progress-widget.tsx +++ b/apps/web/components/dashboard/widgets/project-progress-widget.tsx @@ -58,7 +58,7 @@ export function ProjectProgressWidget() { {project.progress}%
- +
))}
diff --git a/apps/web/components/dashboard/widgets/quick-add-widget.tsx b/apps/web/components/dashboard/widgets/quick-add-widget.tsx index 3d885ba..8d5e38f 100644 --- a/apps/web/components/dashboard/widgets/quick-add-widget.tsx +++ b/apps/web/components/dashboard/widgets/quick-add-widget.tsx @@ -1,15 +1,12 @@ 'use client'; import { Plus } from 'lucide-react'; +import { useRouter } from 'next/navigation'; 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 }) - ); - } + const router = useRouter(); return ( @@ -25,7 +22,7 @@ export function QuickAddWidget() { variant="outline" size="sm" className="justify-start" - onClick={handleQuickAdd} + onClick={() => router.push('/tasks?new=true')} > New task @@ -33,7 +30,7 @@ export function QuickAddWidget() { variant="outline" size="sm" className="justify-start" - onClick={handleQuickAdd} + onClick={() => router.push('/habits?new=true')} > New habit @@ -41,7 +38,7 @@ export function QuickAddWidget() { variant="outline" size="sm" className="justify-start" - onClick={handleQuickAdd} + onClick={() => router.push('/notes?new=true')} > New note diff --git a/apps/web/components/habits/habit-card.tsx b/apps/web/components/habits/habit-card.tsx index 4ffd259..6b24b22 100644 --- a/apps/web/components/habits/habit-card.tsx +++ b/apps/web/components/habits/habit-card.tsx @@ -59,7 +59,7 @@ export function HabitCard({ habit, onComplete }: HabitCardProps) { Score {habit.score}/100
- +
{/* Frequency badge */} diff --git a/apps/web/components/habits/habit-completion-dialog.tsx b/apps/web/components/habits/habit-completion-dialog.tsx index e71fb48..25f11ec 100644 --- a/apps/web/components/habits/habit-completion-dialog.tsx +++ b/apps/web/components/habits/habit-completion-dialog.tsx @@ -89,7 +89,7 @@ export function HabitCompletionDialog({ setQuantity(e.target.value ? Number(e.target.value) : undefined) diff --git a/apps/web/components/habits/habit-heatmap.tsx b/apps/web/components/habits/habit-heatmap.tsx index 04cb2a5..15f192b 100644 --- a/apps/web/components/habits/habit-heatmap.tsx +++ b/apps/web/components/habits/habit-heatmap.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'; import CalendarHeatmap from 'react-calendar-heatmap'; import type { Habit } from '@project-e/shared'; +import { Button } from '@/components/ui/button'; interface HeatmapValue { date: Date | string; @@ -16,6 +17,7 @@ interface HabitHeatmapProps { export function HabitHeatmap({ habits }: HabitHeatmapProps) { const [values, setValues] = useState([]); const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); useEffect(() => { fetchHeatmapData(); @@ -23,6 +25,8 @@ export function HabitHeatmap({ habits }: HabitHeatmapProps) { }, [habits.length]); async function fetchHeatmapData() { + setLoading(true); + setError(null); try { const oneYearAgo = new Date(); oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1); @@ -30,28 +34,30 @@ export function HabitHeatmap({ habits }: HabitHeatmapProps) { 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 = {}; - 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); + if (!response.ok) { + throw new Error('Habit logs could not be loaded.'); } + const data = await response.json(); + const logs: Array<{ logged_at: string }> = data.items || []; + + // Group logs by calendar date. + const byDate: Record = {}; + 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); + setError('Habit activity could not be loaded.'); } finally { setLoading(false); } @@ -61,12 +67,26 @@ export function HabitHeatmap({ habits }: HabitHeatmapProps) { return

Loading...

; } + if (error) { + return ( +
+

{error}

+ +
+ ); + } + const today = new Date(); const oneYearAgo = new Date(); oneYearAgo.setFullYear(today.getFullYear() - 1); return (
+

+ Habit completion heatmap for the past year. {values.length === 0 + ? 'No habit completions recorded.' + : values.map((value) => `${new Date(value.date).toLocaleDateString()}: ${value.count} completion${value.count === 1 ? '' : 's'}.`).join(' ')} +

(null); + const [agentToDelete, setAgentToDelete] = useState(null); + const [error, setError] = useState(null); + const [status, setStatus] = useState(null); useEffect(() => { fetchAgents(); }, []); async function fetchAgents() { + setLoading(true); + setError(null); try { const response = await fetch('/api/agents'); - if (response.ok) { - const data = await response.json(); - setAgents(data.items || []); - } + if (!response.ok) throw new Error('Unable to load agents.'); + const data = await response.json(); + setAgents(data.items || []); } catch (error) { console.error('Failed to fetch agents:', error); + setError('Unable to load agents. Please try again.'); } finally { setLoading(false); } @@ -46,8 +63,11 @@ export function SettingsAgents() { async function createAgent() { if (!newAgentName.trim()) return; + setCreating(true); + setError(null); + setStatus(null); try { - await fetch('/api/agents', { + const response = await fetch('/api/agents', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -56,22 +76,36 @@ export function SettingsAgents() { status: 'active', }), }); + if (!response.ok) throw new Error('Unable to create agent.'); setNewAgentName(''); setCreateDialogOpen(false); - fetchAgents(); + setStatus('Agent created successfully.'); + await fetchAgents(); } catch (error) { console.error('Failed to create agent:', error); + setError('Unable to create agent. Please try again.'); + } finally { + setCreating(false); } } - async function deleteAgent(id: string) { - if (!confirm('Are you sure? This will revoke the agent\'s access.')) return; + async function deleteAgent() { + if (!agentToDelete) return; + setDeletingId(agentToDelete.id); + setError(null); + setStatus(null); try { - await fetch(`/api/agents/${id}`, { method: 'DELETE' }); - fetchAgents(); + const response = await fetch(`/api/agents/${agentToDelete.id}`, { method: 'DELETE' }); + if (!response.ok) throw new Error('Unable to delete agent.'); + setAgentToDelete(null); + setStatus('Agent deleted successfully.'); + await fetchAgents(); } catch (error) { console.error('Failed to delete agent:', error); + setError('Unable to delete agent. Please try again.'); + } finally { + setDeletingId(null); } } @@ -132,6 +166,13 @@ export function SettingsAgents() {
+ {error && ( +
+ {error} + +
+ )} + {status &&

{status}

} {loading ? (

Loading agents... @@ -173,8 +214,9 @@ export function SettingsAgents() { @@ -183,6 +225,20 @@ export function SettingsAgents() { ))}

)} + !open && setAgentToDelete(null)}> + + + Delete {agentToDelete?.name}? + This will revoke the agent's access. + + + Cancel + + {deletingId ? 'Deleting...' : 'Delete agent'} + + + + ); diff --git a/apps/web/components/settings/settings-domains.tsx b/apps/web/components/settings/settings-domains.tsx index ab655c6..99e9986 100644 --- a/apps/web/components/settings/settings-domains.tsx +++ b/apps/web/components/settings/settings-domains.tsx @@ -5,6 +5,16 @@ 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'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; interface Domain { id: string; @@ -18,20 +28,27 @@ export function SettingsDomains() { const [domains, setDomains] = useState([]); const [newDomainName, setNewDomainName] = useState(''); const [loading, setLoading] = useState(true); + const [creating, setCreating] = useState(false); + const [deletingId, setDeletingId] = useState(null); + const [domainToDelete, setDomainToDelete] = useState(null); + const [error, setError] = useState(null); + const [status, setStatus] = useState(null); useEffect(() => { fetchDomains(); }, []); async function fetchDomains() { + setLoading(true); + setError(null); try { const response = await fetch('/api/domains?sort=sort_order'); - if (response.ok) { - const data = await response.json(); - setDomains(data.items || []); - } + if (!response.ok) throw new Error('Unable to load domains.'); + const data = await response.json(); + setDomains(data.items || []); } catch (error) { console.error('Failed to fetch domains:', error); + setError('Unable to load domains. Please try again.'); } finally { setLoading(false); } @@ -40,8 +57,11 @@ export function SettingsDomains() { async function addDomain() { if (!newDomainName.trim()) return; + setCreating(true); + setError(null); + setStatus(null); try { - await fetch('/api/domains', { + const response = await fetch('/api/domains', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -51,21 +71,35 @@ export function SettingsDomains() { sort_order: domains.length, }), }); + if (!response.ok) throw new Error('Unable to add domain.'); setNewDomainName(''); - fetchDomains(); + setStatus('Domain added successfully.'); + await fetchDomains(); } catch (error) { console.error('Failed to add domain:', error); + setError('Unable to add domain. Please try again.'); + } finally { + setCreating(false); } } - async function deleteDomain(id: string) { - if (!confirm('Are you sure? This cannot be undone.')) return; + async function deleteDomain() { + if (!domainToDelete) return; + setDeletingId(domainToDelete.id); + setError(null); + setStatus(null); try { - await fetch(`/api/domains/${id}`, { method: 'DELETE' }); - fetchDomains(); + const response = await fetch(`/api/domains/${domainToDelete.id}`, { method: 'DELETE' }); + if (!response.ok) throw new Error('Unable to delete domain.'); + setDomainToDelete(null); + setStatus('Domain deleted successfully.'); + await fetchDomains(); } catch (error) { console.error('Failed to delete domain:', error); + setError('Unable to delete domain. Please try again.'); + } finally { + setDeletingId(null); } } @@ -76,6 +110,13 @@ export function SettingsDomains() { Manage your workspace domains (e.g., Personal, Work, OTS). + {error && ( +
+ {error} + +
+ )} + {status &&

{status}

} {/* Existing domains */}
{loading ? ( @@ -93,8 +134,9 @@ export function SettingsDomains() { @@ -114,12 +156,27 @@ export function SettingsDomains() { value={newDomainName} onChange={(e) => setNewDomainName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addDomain()} + disabled={creating} /> -
+ !open && setDomainToDelete(null)}> + + + Delete {domainToDelete?.name}? + This cannot be undone. + + + Cancel + + {deletingId ? 'Deleting...' : 'Delete domain'} + + + +
); diff --git a/apps/web/components/settings/settings-import-export.tsx b/apps/web/components/settings/settings-import-export.tsx index 9800366..0aa3668 100644 --- a/apps/web/components/settings/settings-import-export.tsx +++ b/apps/web/components/settings/settings-import-export.tsx @@ -66,16 +66,20 @@ export function SettingsImportExport() { const [importResult, setImportResult] = useState(null); const [confirmImport, setConfirmImport] = useState(false); const [pendingImportFile, setPendingImportFile] = useState(null); + const [exportError, setExportError] = useState(null); + const [importError, setImportError] = useState(null); // ── Export ──────────────────────────────────────────────────────────────── async function handleExport() { setExporting(true); setExportProgress(0); + setExportError(null); + let progressInterval: ReturnType | undefined; try { // Simulate progress while fetching - const progressInterval = setInterval(() => { + progressInterval = setInterval(() => { setExportProgress((prev) => Math.min(prev + 10, 90)); }, 200); @@ -86,6 +90,7 @@ export function SettingsImportExport() { }); clearInterval(progressInterval); + progressInterval = undefined; if (!response.ok) { throw new Error('Export failed'); @@ -108,8 +113,10 @@ export function SettingsImportExport() { toast.success('Export completed successfully'); } catch (error) { console.error('Failed to export:', error); + setExportError('Failed to export data. Please try again.'); toast.error('Failed to export data'); } finally { + if (progressInterval) clearInterval(progressInterval); setExporting(false); setExportProgress(0); } @@ -131,6 +138,7 @@ export function SettingsImportExport() { setPendingImportFile(file); setImportResult(null); + setImportError(null); setConfirmImport(true); } @@ -140,18 +148,21 @@ export function SettingsImportExport() { setConfirmImport(false); setImporting(true); setImportProgress(0); + setImportError(null); + let progressInterval: ReturnType | undefined; try { const text = await pendingImportFile.text(); const data = JSON.parse(text); if (!data.version) { + setImportError('Invalid file. Select a valid Project E export and try again.'); toast.error('Invalid file — missing version field. Is this a valid Project E export?'); return; } // Simulate progress - const progressInterval = setInterval(() => { + progressInterval = setInterval(() => { setImportProgress((prev) => Math.min(prev + 5, 90)); }, 300); @@ -162,16 +173,25 @@ export function SettingsImportExport() { }); clearInterval(progressInterval); + progressInterval = undefined; if (!response.ok) { - const errorData = await response.json(); - toast.error(errorData.error?.message || 'Import failed'); + let message = 'Import failed. Please try again.'; + try { + const errorData = await response.json(); + message = errorData.error?.message || message; + } catch { + // Use the default message when the server does not return JSON. + } + setImportError(message); + toast.error(message); return; } const result: ImportResult = await response.json(); setImportProgress(100); setImportResult(result); + setPendingImportFile(null); if (result.success) { toast.success(`Import complete: ${result.imported} records imported`); @@ -182,10 +202,11 @@ export function SettingsImportExport() { } } catch (error) { console.error('Failed to import:', error); + setImportError('Failed to parse or import the file. Please try again.'); toast.error('Failed to parse import file. Please check the format.'); } finally { + if (progressInterval) clearInterval(progressInterval); setImporting(false); - setPendingImportFile(null); } } @@ -255,7 +276,7 @@ export function SettingsImportExport() { {/* Progress */} {exporting && (
- +

Exporting... {exportProgress}%

)} @@ -272,6 +293,7 @@ export function SettingsImportExport() { )} {exporting ? 'Exporting...' : 'Export to JSON'} + {exportError &&

{exportError}

}
{/* ── Import Section ─────────────────────────────────────────────────── */} @@ -284,7 +306,7 @@ export function SettingsImportExport() { {/* Progress */} {importing && (
- +

Importing... {importProgress}%

)} @@ -356,6 +378,12 @@ export function SettingsImportExport() { + {importError && ( +
+ {importError} + {pendingImportFile && } +
+ )}
{/* ── Import Confirmation Dialog ─────────────────────────────────────── */} diff --git a/apps/web/components/shortcuts-help.tsx b/apps/web/components/shortcuts-help.tsx index e212134..0afb088 100644 --- a/apps/web/components/shortcuts-help.tsx +++ b/apps/web/components/shortcuts-help.tsx @@ -21,7 +21,10 @@ export function ShortcutsHelp() { if ( target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || - target.isContentEditable + target.tagName === 'SELECT' || + target.tagName === 'BUTTON' || + target.isContentEditable || + target.closest('[role="dialog"], [role="menu"], [role="listbox"], [role="combobox"]') ) { return; } diff --git a/apps/web/components/sidebar.tsx b/apps/web/components/sidebar.tsx index 8b18f09..0c13060 100644 --- a/apps/web/components/sidebar.tsx +++ b/apps/web/components/sidebar.tsx @@ -27,6 +27,13 @@ import { TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet'; const navItems = [ { href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard }, @@ -46,13 +53,88 @@ const workspaceItems = [ export function Sidebar() { const pathname = usePathname(); - const { collapsed, toggle } = useSidebarStore(); + const { collapsed, toggle, mobileOpen, setMobileOpen } = useSidebarStore(); + + const navigation = (isCollapsed: boolean, onNavigate?: () => void) => ( + + + + + + ); return ( + + + + Project E + Navigate your workspace. + + {navigation(false, () => setMobileOpen(false))} + + ); } diff --git a/apps/web/components/tasks/task-detail-panel.tsx b/apps/web/components/tasks/task-detail-panel.tsx index 4e66494..7b684bc 100644 --- a/apps/web/components/tasks/task-detail-panel.tsx +++ b/apps/web/components/tasks/task-detail-panel.tsx @@ -1,11 +1,12 @@ 'use client'; import { useState } from 'react'; +import { toast } from 'sonner'; import { Sheet, SheetContent, SheetHeader, - SheetTitle, + SheetTitle } from '@/components/ui/sheet'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -16,8 +17,18 @@ import { SelectContent, SelectItem, SelectTrigger, - SelectValue, + SelectValue } from '@/components/ui/select'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle +} from '@/components/ui/alert-dialog'; interface Task { id: string; @@ -42,7 +53,7 @@ export function TaskDetailPanel({ task, open, onOpenChange, - onUpdate, + onUpdate }: TaskDetailPanelProps) { const [title, setTitle] = useState(task.title); const [description, setDescription] = useState(task.description || ''); @@ -51,11 +62,13 @@ export function TaskDetailPanel({ const [domain, setDomain] = useState(task.domain); const [dueDate, setDueDate] = useState(task.due_date || ''); const [saving, setSaving] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const [deleting, setDeleting] = useState(false); async function handleSave() { setSaving(true); try { - await fetch(`/api/tasks/${task.id}`, { + const response = await fetch(`/api/tasks/${task.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -64,27 +77,37 @@ export function TaskDetailPanel({ status, priority, domain, - due_date: dueDate || null, - }), + due_date: dueDate || null + }) }); + if (!response.ok) throw new Error('Unable to save task'); onUpdate(); onOpenChange(false); + toast.success('Task saved'); } catch (error) { console.error('Failed to update task:', error); + toast.error('Unable to save task'); } finally { setSaving(false); } } async function handleDelete() { - if (!confirm('Are you sure you want to delete this task?')) return; - + setDeleting(true); try { - await fetch(`/api/tasks/${task.id}`, { method: 'DELETE' }); + const response = await fetch(`/api/tasks/${task.id}`, { + method: 'DELETE' + }); + if (!response.ok) throw new Error('Unable to delete task'); onUpdate(); + setDeleteOpen(false); onOpenChange(false); + toast.success('Task deleted'); } catch (error) { console.error('Failed to delete task:', error); + toast.error('Unable to delete task'); + } finally { + setDeleting(false); } } @@ -189,13 +212,37 @@ export function TaskDetailPanel({
+ + + + + Delete task? + + This permanently deletes "{task.title}". + + + + Cancel + { + event.preventDefault(); + handleDelete(); + }} + disabled={deleting} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {deleting ? 'Deleting...' : 'Delete'} + + + + ); diff --git a/apps/web/components/tasks/tasks-kanban-view.tsx b/apps/web/components/tasks/tasks-kanban-view.tsx index 2cf4559..432dd55 100644 --- a/apps/web/components/tasks/tasks-kanban-view.tsx +++ b/apps/web/components/tasks/tasks-kanban-view.tsx @@ -2,17 +2,30 @@ import { useEffect, useState } from 'react'; import { + KeyboardSensor, DndContext, DragEndEvent, DragOverlay, DragStartEvent, + PointerSensor, + useSensor, + useSensors, useDraggable, - useDroppable, + useDroppable } from '@dnd-kit/core'; +import { sortableKeyboardCoordinates } from '@dnd-kit/sortable'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; -import { Calendar, MoreHorizontal } from 'lucide-react'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select'; +import { Calendar, GripVertical } from 'lucide-react'; +import { toast } from 'sonner'; import { TaskDetailPanel } from './task-detail-panel'; interface Task { @@ -30,25 +43,27 @@ interface Task { const columns = [ { id: 'todo', title: 'To Do', color: 'bg-slate-500' }, { id: 'in_progress', title: 'In Progress', color: 'bg-blue-500' }, - { id: 'done', title: 'Done', color: 'bg-green-500' }, + { id: 'done', title: 'Done', color: 'bg-green-500' } ]; function DraggableTask({ task, onClick, + onStatusChange }: { task: Task; onClick: () => void; + onStatusChange: (task: Task, status: Task['status']) => void; }) { const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: task.id, - data: { task }, + data: { task } }); const style = transform ? { - transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, + transform: `translate3d(${transform.x}px, ${transform.y}px, 0)` } : undefined; @@ -56,11 +71,7 @@ function DraggableTask({
@@ -77,12 +88,32 @@ function DraggableTask({
+
void; + onStatusChange: (task: Task, status: Task['status']) => void; }) { const { setNodeRef, isOver } = useDroppable({ id }); @@ -132,9 +165,7 @@ function DroppableColumn({
onTaskClick(task)} + onStatusChange={onStatusChange} /> ))}
@@ -161,6 +193,12 @@ export function TasksKanbanView() { const [loading, setLoading] = useState(true); const [activeTask, setActiveTask] = useState(null); const [selectedTask, setSelectedTask] = useState(null); + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates + }) + ); useEffect(() => { fetchTasks(); @@ -169,12 +207,14 @@ export function TasksKanbanView() { async function fetchTasks() { try { const response = await fetch('/api/tasks?sort=-created'); - if (response.ok) { - const data = await response.json(); - setTasks(data.items || []); + if (!response.ok) { + throw new Error('Unable to load tasks'); } + const data = await response.json(); + setTasks(data.items || []); } catch (error) { console.error('Failed to fetch tasks:', error); + toast.error('Unable to load tasks'); } finally { setLoading(false); } @@ -182,23 +222,18 @@ export function TasksKanbanView() { async function handleDragEnd(event: DragEndEvent) { const { active, over } = event; - if (!over) return; + if (!over) { + setActiveTask(null); + return; + } const task = active.data.current?.task as Task; const newStatus = over.id as Task['status']; if (task.status !== newStatus) { - try { - await fetch(`/api/tasks/${task.id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ status: newStatus }), - }); - fetchTasks(); - } catch (error) { - console.error('Failed to update task status:', error); - } + await updateTaskStatus(task, newStatus); } + setActiveTask(null); } function handleDragStart(event: DragStartEvent) { @@ -206,13 +241,39 @@ export function TasksKanbanView() { setActiveTask(task); } + async function updateTaskStatus(task: Task, status: Task['status']) { + if (task.status === status) return; + + try { + const response = await fetch(`/api/tasks/${task.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status }) + }); + if (!response.ok) throw new Error('Unable to move task'); + + await fetchTasks(); + toast.success( + `Moved ${task.title} to ${columns.find((column) => column.id === status)?.title}` + ); + } catch (error) { + console.error('Failed to update task status:', error); + toast.error(`Unable to move ${task.title}`); + } + } + if (loading) { return

Loading tasks...

; } return ( <> - + setActiveTask(null)} + >
{columns.map((column) => ( t.status === column.id)} onTaskClick={setSelectedTask} + onStatusChange={updateTaskStatus} /> ))}
diff --git a/apps/web/components/tasks/tasks-list-view.tsx b/apps/web/components/tasks/tasks-list-view.tsx index 628f1dd..ab21bb2 100644 --- a/apps/web/components/tasks/tasks-list-view.tsx +++ b/apps/web/components/tasks/tasks-list-view.tsx @@ -7,12 +7,14 @@ import { TableCell, TableHead, TableHeader, - TableRow, + TableRow } from '@/components/ui/table'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; +import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'; import { Calendar, MoreHorizontal } from 'lucide-react'; +import { toast } from 'sonner'; import { TaskDetailPanel } from './task-detail-panel'; interface Task { @@ -39,12 +41,14 @@ export function TasksListView() { async function fetchTasks() { try { const response = await fetch('/api/tasks?sort=-created'); - if (response.ok) { - const data = await response.json(); - setTasks(data.items || []); + if (!response.ok) { + throw new Error('Unable to load tasks'); } + const data = await response.json(); + setTasks(data.items || []); } catch (error) { console.error('Failed to fetch tasks:', error); + toast.error('Unable to load tasks'); } finally { setLoading(false); } @@ -53,14 +57,19 @@ export function TasksListView() { async function toggleTaskComplete(task: Task) { const newStatus = task.status === 'done' ? 'todo' : 'done'; try { - await fetch(`/api/tasks/${task.id}`, { + const response = await fetch(`/api/tasks/${task.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ status: newStatus }), + body: JSON.stringify({ status: newStatus }) }); - fetchTasks(); + if (!response.ok) throw new Error('Unable to update task'); + await fetchTasks(); + toast.success( + `Marked ${task.title} as ${newStatus === 'done' ? 'complete' : 'incomplete'}` + ); } catch (error) { console.error('Failed to toggle task:', error); + toast.error(`Unable to update ${task.title}`); } } @@ -70,77 +79,82 @@ export function TasksListView() { return ( <> - - - - - Task - Priority - Domain - Due Date - - - - - {tasks.map((task) => ( - - - toggleTaskComplete(task)} - aria-label={`Mark "${task.title}" as ${task.status === 'done' ? 'incomplete' : 'complete'}`} - /> - - - - - - - {task.priority} - - - - {task.domain} - - - {task.due_date && ( - - - {new Date(task.due_date).toLocaleDateString()} - - )} - - - - - - ))} - -
+ +
+ + + + + Task + Priority + Domain + Due Date + + + + + {tasks.map((task) => ( + + + toggleTaskComplete(task)} + aria-label={`Mark "${task.title}" as ${task.status === 'done' ? 'incomplete' : 'complete'}`} + /> + + + + + + + {task.priority} + + + + {task.domain} + + + {task.due_date && ( + + + {new Date(task.due_date).toLocaleDateString()} + + )} + + + + + + ))} + +
+
+ +
{selectedTask && ( @@ -46,14 +56,16 @@ export function TopBar() { variant="outline" size="sm" onClick={() => { - document.dispatchEvent( - new KeyboardEvent('keydown', { key: 'k', metaKey: true }) - ); + if (creation) { + router.push(creation.href); + return; + } + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true })); }} - aria-label="Quick add" + aria-label={creation ? `Create ${creation.label.toLowerCase()}` : 'Quick add'} >