Loading analytics...
;
}
+ if (error || !analytics) {
+ 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 (
+
+ );
+ }
+
return (
@@ -188,17 +207,6 @@ export default function CalendarPage() {
Tasks
-
-
setShowHabits(checked === true)}
- />
-
-
= {
export default function DashboardPage() {
const { widgets, setWidgets } = useDashboardStore();
+ const [layoutAnnouncement, setLayoutAnnouncement] = React.useState('');
const layout = widgets.map((w) => ({
i: w.id,
@@ -134,7 +136,7 @@ export default function DashboardPage() {
h: w.h,
}));
- function handleLayoutChange(newLayout: { i: string; x: number; y: number; w: number; h: number }[]) {
+ function handleLayoutChange(newLayout: ReadonlyArray<{ 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) {
@@ -151,6 +153,31 @@ export default function DashboardPage() {
setWidgets(updated);
}
+ function moveWidget(id: string, direction: -1 | 1) {
+ const ordered = [...widgets].sort((a, b) => a.y - b.y || a.x - b.x);
+ const index = ordered.findIndex((widget) => widget.id === id);
+ const targetIndex = index + direction;
+ if (index < 0 || targetIndex < 0 || targetIndex >= ordered.length) return;
+
+ const current = ordered[index];
+ const target = ordered[targetIndex];
+ setWidgets(widgets.map((widget) => {
+ if (widget.id === current.id) return { ...widget, x: target.x, y: target.y };
+ if (widget.id === target.id) return { ...widget, x: current.x, y: current.y };
+ return widget;
+ }));
+ setLayoutAnnouncement(`${current.type} moved ${direction < 0 ? 'earlier' : 'later'} on the dashboard.`);
+ }
+
+ function resizeWidget(id: string, direction: -1 | 1) {
+ const widget = widgets.find((item) => item.id === id);
+ if (!widget) return;
+ const width = Math.max(2, Math.min(12, widget.w + direction));
+ if (width === widget.w) return;
+ setWidgets(widgets.map((item) => item.id === id ? { ...item, w: width } : item));
+ setLayoutAnnouncement(`${widget.type} is now ${width} columns wide.`);
+ }
+
return (
@@ -158,6 +185,35 @@ export default function DashboardPage() {
Your day, at a glance.
+
+ Customize dashboard layout
+
+ Use these controls to reorder or resize widgets without dragging.
+
+
+ {[...widgets].sort((a, b) => a.y - b.y || a.x - b.x).map((widget, index, ordered) => (
+
+
{widget.type}
+
+
+
+
+
+
+
+ ))}
+
+
+
{layoutAnnouncement}
+
{widgets.map((widget) => {
const WidgetComponent = widgetComponents[widget.id];
diff --git a/apps/web/app/(dashboard)/habits/page.tsx b/apps/web/app/(dashboard)/habits/page.tsx
index 59051fc..20eeb3c 100644
--- a/apps/web/app/(dashboard)/habits/page.tsx
+++ b/apps/web/app/(dashboard)/habits/page.tsx
@@ -8,6 +8,8 @@ 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';
+import { CreateItemDialog } from '@/components/create-item-dialog';
+import { toast } from 'sonner';
// Lazy load react-calendar-heatmap (~15KB)
const HabitHeatmap = dynamic(
@@ -28,22 +30,36 @@ interface HabitWithMeta extends Habit {
export default function HabitsPage() {
const [habits, setHabits] = useState([]);
const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
const [selectedHabit, setSelectedHabit] = useState(null);
const [completionDialogOpen, setCompletionDialogOpen] = useState(false);
+ const [createOpen, setCreateOpen] = useState(false);
useEffect(() => {
fetchHabits();
}, []);
+ 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, '', '/habits');
+ }
+ }
+
async function fetchHabits() {
try {
+ setError(null);
const response = await fetch('/api/habits');
- if (response.ok) {
- const data = await response.json();
- setHabits(data.items || []);
- }
+ if (!response.ok) throw new Error('Unable to load habits.');
+ const data = await response.json();
+ setHabits(data.items || []);
} catch (error) {
console.error('Failed to fetch habits:', error);
+ setError('Unable to load habits. Please try again.');
} finally {
setLoading(false);
}
@@ -63,15 +79,18 @@ export default function HabitsPage() {
data: { mood?: number; value?: number; notes?: string }
) {
try {
- await fetch(`/api/habits/${habitId}/logs`, {
+ const response = await fetch(`/api/habits/${habitId}/logs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
+ if (!response.ok) throw new Error('Unable to save habit completion.');
fetchHabits();
setCompletionDialogOpen(false);
+ toast.success('Habit completed.');
} catch (error) {
console.error('Failed to log habit:', error);
+ toast.error('Unable to save habit completion. Please try again.');
}
}
@@ -80,7 +99,7 @@ export default function HabitsPage() {
habits.length > 0 ? Math.round((completedCount / habits.length) * 100) : 0;
if (loading) {
- return Loading habits...
;
+ return Loading habits...
;
}
return (
@@ -92,12 +111,19 @@ export default function HabitsPage() {
Small actions, visible momentum.
-
+ {error && (
+
+ {error}
+ Retry
+
+ )}
+
{/* Summary banner */}
@@ -116,7 +142,14 @@ export default function HabitsPage() {
{/* Habit cards grid */}
- {habits.map((habit) => (
+ {habits.length === 0 && !error ? (
+
+
+ No habits yet. Start with one small action.
+ setCreateOpen(true)}>Create a habit
+
+
+ ) : habits.map((habit) => (
logHabitCompletion(selectedHabit.id, data)}
/>
)}
+
);
}
diff --git a/apps/web/app/(dashboard)/notes/page.tsx b/apps/web/app/(dashboard)/notes/page.tsx
index 801ea7f..04bb11f 100644
--- a/apps/web/app/(dashboard)/notes/page.tsx
+++ b/apps/web/app/(dashboard)/notes/page.tsx
@@ -1,14 +1,26 @@
'use client';
-import { useEffect, useState, Suspense } from 'react';
-import { Plus, FileText, Link2, GitBranch } from 'lucide-react';
+import { useEffect, useRef, useState, Suspense } from 'react';
+import { Plus, FileText, Link2, GitBranch, 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 { 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';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from '@/components/ui/alert-dialog';
// Lazy load TipTap editor (~80KB TipTap + extensions)
const NoteEditor = dynamic(
@@ -55,9 +67,30 @@ export default function NotesPage() {
const [selectedNote, setSelectedNote] = useState(null);
const [backlinks, setBacklinks] = useState([]);
const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [backlinksError, setBacklinksError] = useState(null);
+ const [backlinksLoading, setBacklinksLoading] = useState(false);
+ const [saveStatus, setSaveStatus] = useState<'Saved' | 'Saving' | 'Failed'>('Saved');
+ const [noteToDelete, setNoteToDelete] = useState(null);
+ const [deleting, setDeleting] = useState(false);
+ const createdFromQuery = useRef(false);
+ const pendingSave = useRef<{ id: string; updates: Partial } | null>(null);
+ const saveTimer = useRef | null>(null);
+ const saveVersion = useRef(0);
useEffect(() => {
fetchNotes();
+ return () => {
+ if (saveTimer.current) clearTimeout(saveTimer.current);
+ };
+ }, []);
+
+ useEffect(() => {
+ if (new URLSearchParams(window.location.search).get('new') !== 'true' || createdFromQuery.current) return;
+ createdFromQuery.current = true;
+ createNote().finally(() => window.history.replaceState(null, '', '/notes'));
+ // Route-triggered creation should only run once per page visit.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
@@ -67,32 +100,36 @@ export default function NotesPage() {
}, [selectedNote]);
async function fetchNotes() {
+ setLoading(true);
+ setError(null);
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]);
- }
- }
+ if (!response.ok) throw new Error('Unable to load notes.');
+ const data = await response.json();
+ const notesList = data.items || [];
+ setNotes(notesList);
+ setSelectedNote((current) => current || notesList[0] || null);
} catch (error) {
console.error('Failed to fetch notes:', error);
+ setError('Unable to load notes. Please try again.');
} finally {
setLoading(false);
}
}
async function fetchBacklinks(noteId: string) {
+ setBacklinksLoading(true);
+ setBacklinksError(null);
try {
const response = await fetch(`/api/notes/${noteId}/backlinks`);
- if (response.ok) {
- const data = await response.json();
- setBacklinks(data.items || []);
- }
+ if (!response.ok) throw new Error('Unable to load backlinks.');
+ const data = await response.json();
+ setBacklinks(data.items || []);
} catch (error) {
console.error('Failed to fetch backlinks:', error);
+ setBacklinksError('Unable to load backlinks.');
+ } finally {
+ setBacklinksLoading(false);
}
}
@@ -107,13 +144,14 @@ export default function NotesPage() {
domain: 'personal',
}),
});
- if (response.ok) {
- const newNote = await response.json();
- setNotes([newNote, ...notes]);
- setSelectedNote(newNote);
- }
+ if (!response.ok) throw new Error('Unable to create note.');
+ const newNote = await response.json();
+ setNotes((current) => [newNote, ...current]);
+ setSelectedNote(newNote);
+ toast.success('Note created');
} catch (error) {
console.error('Failed to create note:', error);
+ toast.error('Unable to create note');
}
}
@@ -126,45 +164,94 @@ export default function NotesPage() {
return;
}
// Otherwise prepend it and select
- setNotes([note, ...notes]);
+ setNotes((current) => [note, ...current]);
setSelectedNote(note);
}
- async function updateNote(noteId: string, updates: Partial) {
+ function scheduleSave(noteId: string, updates: Partial) {
+ const version = ++saveVersion.current;
+ pendingSave.current = {
+ id: noteId,
+ updates: { ...(pendingSave.current?.id === noteId ? pendingSave.current.updates : {}), ...updates },
+ };
+ if (saveTimer.current) clearTimeout(saveTimer.current);
+ setSaveStatus('Saving');
+ saveTimer.current = setTimeout(() => {
+ const save = pendingSave.current;
+ pendingSave.current = null;
+ if (save) updateNote(save.id, save.updates, version);
+ }, 700);
+ }
+
+ async function updateNote(noteId: string, updates: Partial, version: number) {
try {
- await fetch(`/api/notes/${noteId}`, {
+ const response = await fetch(`/api/notes/${noteId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
- fetchNotes();
+ if (!response.ok) throw new Error('Unable to save note.');
+ const updated = await response.json();
+ setNotes((current) => current.map((note) => (note.id === noteId ? updated : note)));
+ if (saveVersion.current === version) setSaveStatus('Saved');
} catch (error) {
console.error('Failed to update note:', error);
+ if (saveVersion.current === version) setSaveStatus('Failed');
+ toast.error('Unable to save note');
}
}
- async function deleteNote(noteId: string) {
- if (!confirm('Are you sure you want to delete this note?')) return;
-
+ async function deleteNote() {
+ if (!noteToDelete) return;
+ if (pendingSave.current?.id === noteToDelete.id && saveTimer.current) {
+ clearTimeout(saveTimer.current);
+ pendingSave.current = null;
+ }
+ ++saveVersion.current;
+ setDeleting(true);
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);
- }
+ const response = await fetch(`/api/notes/${noteToDelete.id}`, { method: 'DELETE' });
+ if (!response.ok) throw new Error('Unable to delete note.');
+ setNotes((current) => current.filter((note) => note.id !== noteToDelete.id));
+ setSelectedNote((selected) =>
+ selected?.id === noteToDelete.id ? notes.find((note) => note.id !== noteToDelete.id) || null : selected
+ );
+ setNoteToDelete(null);
+ toast.success('Note deleted');
} catch (error) {
console.error('Failed to delete note:', error);
+ toast.error('Unable to delete note');
+ } finally {
+ setDeleting(false);
+ }
+ }
+
+ async function openBacklink(link: Backlink) {
+ const existing = notes.find((note) => note.id === link.id);
+ if (existing) return setSelectedNote(existing);
+ try {
+ const response = await fetch(`/api/notes/${link.id}`);
+ if (!response.ok) throw new Error('Unable to load linked note.');
+ const note = await response.json() as Note;
+ setNotes((current) => [note, ...current]);
+ setSelectedNote(note);
+ } catch (error) {
+ console.error('Failed to fetch linked note:', error);
+ toast.error('Unable to open linked note');
}
}
if (loading) {
- return Loading notes...
;
+ return Loading notes...
;
+ }
+
+ if (error) {
+ return ;
}
return (
-
+
Notes
@@ -182,13 +269,17 @@ export default function NotesPage() {
{/* Notes list */}
-
-
+
+
{notes.length === 0 ? (
-
- No notes yet
-
+
+
No notes yet
+
+
+ Create your first note
+
+
) : (
{notes.map((note) => (
@@ -226,31 +317,42 @@ export default function NotesPage() {
{/* Note editor */}
-
+
{selectedNote ? (
- setSelectedNote({ ...selectedNote, content })
- }
- onBlur={() =>
- updateNote(selectedNote.id, {
- content: selectedNote.content,
- })
- }
+ onChange={(content) => { setSelectedNote({ ...selectedNote, content }); scheduleSave(selectedNote.id, { content }); }}
/>
@@ -286,7 +381,7 @@ export default function NotesPage() {
{/* Backlinks and graph */}
-
+
@@ -302,7 +397,7 @@ export default function NotesPage() {
- {backlinks.length === 0 ? (
+ {backlinksLoading ? Loading backlinks...
: backlinksError ? {backlinksError}
selectedNote && fetchBacklinks(selectedNote.id)}>Retry : backlinks.length === 0 ? (
No backlinks
@@ -311,10 +406,7 @@ export default function NotesPage() {
{backlinks.map((link) => (
{
- const note = notes.find((n) => n.id === link.id);
- if (note) setSelectedNote(note);
- }}
+ onClick={() => openBacklink(link)}
aria-label={`Open linked note: ${link.title}`}
className="w-full rounded-lg border p-3 text-left transition-colors hover:bg-accent"
>
diff --git a/apps/web/app/(dashboard)/projects/[id]/page.tsx b/apps/web/app/(dashboard)/projects/[id]/page.tsx
index fdff9b4..a9def7d 100644
--- a/apps/web/app/(dashboard)/projects/[id]/page.tsx
+++ b/apps/web/app/(dashboard)/projects/[id]/page.tsx
@@ -162,7 +162,7 @@ export default function ProjectDetailPage() {
-
+
@@ -361,6 +361,7 @@ export default function ProjectDetailPage() {
: 0
}
className="h-1.5"
+ aria-label={`${milestone.name} task progress: ${milestone.completed_tasks} of ${milestone.total_tasks}`}
/>
diff --git a/apps/web/app/(dashboard)/projects/page.tsx b/apps/web/app/(dashboard)/projects/page.tsx
index 10a3740..3af0046 100644
--- a/apps/web/app/(dashboard)/projects/page.tsx
+++ b/apps/web/app/(dashboard)/projects/page.tsx
@@ -7,6 +7,7 @@ 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';
+import { CreateItemDialog } from '@/components/create-item-dialog';
interface Project {
id: string;
@@ -23,27 +24,41 @@ interface Project {
export default function ProjectsPage() {
const [projects, setProjects] = useState
([]);
const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [createOpen, setCreateOpen] = useState(false);
useEffect(() => {
fetchProjects();
}, []);
+ 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, '', '/projects');
+ }
+ }
+
async function fetchProjects() {
try {
+ setError(null);
const response = await fetch('/api/projects?sort=-created');
- if (response.ok) {
- const data = await response.json();
- setProjects(data.items || []);
- }
+ if (!response.ok) throw new Error('Unable to load projects.');
+ const data = await response.json();
+ setProjects(data.items || []);
} catch (error) {
console.error('Failed to fetch projects:', error);
+ setError('Unable to load projects. Please try again.');
} finally {
setLoading(false);
}
}
if (loading) {
- return Loading projects...
;
+ return Loading projects...
;
}
const activeProjects = projects.filter((p) => p.status === 'active');
@@ -57,12 +72,19 @@ export default function ProjectsPage() {
Projects
Every outcome has a home.
-
-
+ setCreateOpen(true)}>
+
New project
+ {error && (
+
+ {error}
+ Retry
+
+ )}
+
{/* Active projects */}
{activeProjects.length > 0 && (
@@ -107,9 +129,16 @@ export default function ProjectsPage() {
Create your first project to get started
+ setCreateOpen(true)}>Create a project
)}
+
);
}
@@ -149,7 +178,7 @@ function ProjectCard({ project }: { project: Project }) {
Progress
{project.progress}%
-
+
{/* 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 ;
}
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
+
createReport()}>
+
+ Create your first report
+
+
) : (
{reports.map((report) => (
@@ -236,26 +297,35 @@ export default function ReportsPage() {
{/* Report editor */}
-
+
{selectedReport ? (
{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
-
+
+ setConfirmClear(true)} aria-label="Clear all error logs">
+
+ Clear all
+
+
+
+ Clear all error logs?
+ This permanently removes all displayed error logs.
+
+
+ Cancel
+
+ {clearing ? 'Clearing...' : 'Clear all'}
+
+
+
+
)}
+ {error && (
+
+ {error}
+ Retry
+
+ )}
+ {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.
-
-
-
+
+
+
Appearance
-
+
Domains
-
+
Keyboard Shortcuts
-
+
Agents & Permissions
-
+
Webhooks
-
+
Import & Export
-
+
Error Log
-
+
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() {
+
setCreateOpen(true)}>
+
+ New task
+
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 (
+
+ );
+}
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
-