feat: update ProjectE application
This commit is contained in:
@@ -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() {
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
{errorMessage && (
|
||||
<div id="login-error" role="alert" className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
@@ -56,6 +64,7 @@ export default function LoginPage() {
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
aria-describedby={errorMessage ? 'login-error' : undefined}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -66,6 +75,7 @@ export default function LoginPage() {
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
aria-describedby={errorMessage ? 'login-error' : undefined}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -43,7 +43,14 @@ export default function AgentsPage() {
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [activity, setActivity] = useState<AgentActivity[]>([]);
|
||||
const [agentTasks, setAgentTasks] = useState<AgentTask[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [agentsLoading, setAgentsLoading] = useState(true);
|
||||
const [activityLoading, setActivityLoading] = useState(true);
|
||||
const [agentTasksLoading, setAgentTasksLoading] = useState(true);
|
||||
const [agentsError, setAgentsError] = useState<string | null>(null);
|
||||
const [activityError, setActivityError] = useState<string | null>(null);
|
||||
const [agentTasksError, setAgentTasksError] = useState<string | null>(null);
|
||||
const [undoingActivityId, setUndoingActivityId] = useState<string | null>(null);
|
||||
const [feedback, setFeedback] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
||||
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(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 <p className="text-muted-foreground">Loading agent activity...</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
@@ -141,6 +169,16 @@ export default function AgentsPage() {
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Every agent action, visible and reversible.
|
||||
</p>
|
||||
{feedback && (
|
||||
<p
|
||||
className={`mt-3 text-sm ${
|
||||
feedback.type === 'success' ? 'text-green-600' : 'text-red-600'
|
||||
}`}
|
||||
role={feedback.type === 'error' ? 'alert' : 'status'}
|
||||
>
|
||||
{feedback.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[300px_1fr]">
|
||||
@@ -150,10 +188,22 @@ export default function AgentsPage() {
|
||||
<CardTitle className="text-base">Agents</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{agents.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
No agents configured
|
||||
</p>
|
||||
{agentsLoading ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">Loading agents...</p>
|
||||
) : agentsError ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-sm text-red-600" role="alert">{agentsError}</p>
|
||||
<Button variant="outline" size="sm" className="mt-3" onClick={fetchAgents}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : agents.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">No agents configured</p>
|
||||
<Button variant="outline" size="sm" className="mt-3" onClick={fetchAgents}>
|
||||
Refresh agents
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{agents.map((agent) => (
|
||||
@@ -217,10 +267,22 @@ export default function AgentsPage() {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="activity" className="mt-4">
|
||||
{activity.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
No agent activity yet
|
||||
</p>
|
||||
{activityLoading ? (
|
||||
<p className="py-8 text-center text-muted-foreground">Loading agent activity...</p>
|
||||
) : activityError ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-sm text-red-600" role="alert">{activityError}</p>
|
||||
<Button variant="outline" size="sm" className="mt-3" onClick={fetchActivity}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : activity.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-muted-foreground">No agent activity yet</p>
|
||||
<Button variant="outline" size="sm" className="mt-3" onClick={fetchActivity}>
|
||||
Refresh activity
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{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}`}
|
||||
>
|
||||
<RotateCcw className="mr-1 h-3 w-3" aria-hidden="true" />
|
||||
Undo
|
||||
{undoingActivityId === item.id ? 'Undoing...' : 'Undo'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -291,10 +354,22 @@ export default function AgentsPage() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tasks" className="mt-4">
|
||||
{agentTasks.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
No agent tasks yet
|
||||
</p>
|
||||
{agentTasksLoading ? (
|
||||
<p className="py-8 text-center text-muted-foreground">Loading agent tasks...</p>
|
||||
) : agentTasksError ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-sm text-red-600" role="alert">{agentTasksError}</p>
|
||||
<Button variant="outline" size="sm" className="mt-3" onClick={fetchAgentTasks}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : agentTasks.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-muted-foreground">No agent tasks yet</p>
|
||||
<Button variant="outline" size="sm" className="mt-3" onClick={fetchAgentTasks}>
|
||||
Refresh tasks
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{agentTasks.map((task) => (
|
||||
|
||||
@@ -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<Record<string, unknown>>, field: string) {
|
||||
return records.reduce<Record<string, number>>((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<AnalyticsData | null>(null);
|
||||
const [timeData, setTimeData] = useState<TimeData[]>([]);
|
||||
const [domainData, setDomainData] = useState<DomainData[]>([]);
|
||||
const [habitData, setHabitData] = useState<HabitData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 <p className="text-muted-foreground">Loading analytics...</p>;
|
||||
}
|
||||
|
||||
if (error || !analytics) {
|
||||
return (
|
||||
<div className="space-y-4 py-20 text-center">
|
||||
<p className="text-muted-foreground" role="alert">{error || 'Analytics are unavailable.'}</p>
|
||||
<Button onClick={fetchAnalytics}>Retry</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
|
||||
@@ -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<CalendarEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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<string[]>([]);
|
||||
@@ -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 (
|
||||
<div className="space-y-4 py-20 text-center">
|
||||
<p className="text-muted-foreground" role="alert">{error}</p>
|
||||
<Button onClick={fetchEvents}>Retry</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
@@ -188,17 +207,6 @@ export default function CalendarPage() {
|
||||
Tasks
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="habits"
|
||||
checked={showHabits}
|
||||
onCheckedChange={(checked) => setShowHabits(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="habits" className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded" style={{ backgroundColor: '#10b981' }} />
|
||||
Habits
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="projects"
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, { Suspense } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useDashboardStore } from '@/lib/stores/use-dashboard-store';
|
||||
import { WidgetErrorBoundary } from '@/components/widget-error-boundary';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
// Lazy load react-grid-layout (client-only, ~45KB)
|
||||
const ResponsiveGridLayout = dynamic(
|
||||
@@ -125,6 +126,7 @@ const widgetComponents: Record<string, React.ComponentType> = {
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
@@ -158,6 +185,35 @@ export default function DashboardPage() {
|
||||
<p className="mt-1 text-muted-foreground">Your day, at a glance.</p>
|
||||
</div>
|
||||
|
||||
<details className="mb-4 rounded-lg border bg-card p-3">
|
||||
<summary className="cursor-pointer text-sm font-medium">Customize dashboard layout</summary>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Use these controls to reorder or resize widgets without dragging.
|
||||
</p>
|
||||
<div className="mt-3 space-y-2">
|
||||
{[...widgets].sort((a, b) => a.y - b.y || a.x - b.x).map((widget, index, ordered) => (
|
||||
<div key={widget.id} className="flex items-center justify-between gap-3 rounded-md bg-muted/50 px-3 py-2">
|
||||
<span className="text-sm">{widget.type}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => moveWidget(widget.id, -1)} disabled={index === 0}>
|
||||
Move earlier
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => moveWidget(widget.id, 1)} disabled={index === ordered.length - 1}>
|
||||
Move later
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => resizeWidget(widget.id, -1)} disabled={widget.w <= 2}>
|
||||
Narrower
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => resizeWidget(widget.id, 1)} disabled={widget.w >= 12}>
|
||||
Wider
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
<p className="sr-only" aria-live="polite">{layoutAnnouncement}</p>
|
||||
|
||||
<ResponsiveGridLayout layout={layout} onLayoutChange={handleLayoutChange}>
|
||||
{widgets.map((widget) => {
|
||||
const WidgetComponent = widgetComponents[widget.id];
|
||||
|
||||
@@ -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<HabitWithMeta[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedHabit, setSelectedHabit] = useState<HabitWithMeta | null>(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 <p className="text-muted-foreground">Loading habits...</p>;
|
||||
return <p role="status" className="text-muted-foreground">Loading habits...</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -92,12 +111,19 @@ export default function HabitsPage() {
|
||||
Small actions, visible momentum.
|
||||
</p>
|
||||
</div>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
New habit
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div role="alert" className="mb-6 flex items-center justify-between gap-4 rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
<span>{error}</span>
|
||||
<Button variant="outline" size="sm" onClick={fetchHabits}>Retry</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary banner */}
|
||||
<Card className="mb-6">
|
||||
<CardContent className="flex items-center justify-between p-6">
|
||||
@@ -116,7 +142,14 @@ export default function HabitsPage() {
|
||||
|
||||
{/* Habit cards grid */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{habits.map((habit) => (
|
||||
{habits.length === 0 && !error ? (
|
||||
<Card className="md:col-span-2 lg:col-span-3">
|
||||
<CardContent className="py-10 text-center">
|
||||
<p className="text-muted-foreground">No habits yet. Start with one small action.</p>
|
||||
<Button className="mt-4" onClick={() => setCreateOpen(true)}>Create a habit</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : habits.map((habit) => (
|
||||
<HabitCard
|
||||
key={habit.id}
|
||||
habit={habit}
|
||||
@@ -153,6 +186,12 @@ export default function HabitsPage() {
|
||||
onSubmit={(data) => logHabitCompletion(selectedHabit.id, data)}
|
||||
/>
|
||||
)}
|
||||
<CreateItemDialog
|
||||
type="habit"
|
||||
open={createOpen}
|
||||
onOpenChange={handleCreateOpenChange}
|
||||
onCreated={fetchHabits}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Note | null>(null);
|
||||
const [backlinks, setBacklinks] = useState<Backlink[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [backlinksError, setBacklinksError] = useState<string | null>(null);
|
||||
const [backlinksLoading, setBacklinksLoading] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<'Saved' | 'Saving' | 'Failed'>('Saved');
|
||||
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const createdFromQuery = useRef(false);
|
||||
const pendingSave = useRef<{ id: string; updates: Partial<Note> } | null>(null);
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | 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<Note>) {
|
||||
function scheduleSave(noteId: string, updates: Partial<Note>) {
|
||||
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<Note>, 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 <p className="text-muted-foreground">Loading notes...</p>;
|
||||
return <p className="text-muted-foreground" role="status">Loading notes...</p>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="space-y-3"><p className="text-muted-foreground" role="alert">{error}</p><Button onClick={fetchNotes}>Retry</Button></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Notes</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
@@ -182,13 +269,17 @@ export default function NotesPage() {
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[250px_1fr_300px]">
|
||||
{/* Notes list */}
|
||||
<Card className="h-[calc(100vh-200px)]">
|
||||
<ScrollArea className="h-full">
|
||||
<Card className="max-h-80 lg:h-[calc(100vh-200px)] lg:max-h-none">
|
||||
<ScrollArea className="max-h-80 lg:h-full lg:max-h-none">
|
||||
<div className="p-2">
|
||||
{notes.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
No notes yet
|
||||
</p>
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">No notes yet</p>
|
||||
<Button className="mt-3" size="sm" onClick={createNote}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create your first note
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{notes.map((note) => (
|
||||
@@ -226,31 +317,42 @@ export default function NotesPage() {
|
||||
</Card>
|
||||
|
||||
{/* Note editor */}
|
||||
<Card className="h-[calc(100vh-200px)]">
|
||||
<Card className="min-h-[420px] lg:h-[calc(100vh-200px)]">
|
||||
{selectedNote ? (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b p-4">
|
||||
<label htmlFor="note-title" className="sr-only">
|
||||
Note title
|
||||
</label>
|
||||
<input
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
id="note-title"
|
||||
type="text"
|
||||
value={selectedNote.title}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
const title = e.target.value;
|
||||
setSelectedNote({
|
||||
...selectedNote,
|
||||
title: e.target.value,
|
||||
})
|
||||
}
|
||||
onBlur={() =>
|
||||
updateNote(selectedNote.id, {
|
||||
title: selectedNote.title,
|
||||
})
|
||||
}
|
||||
className="w-full text-xl font-semibold outline-none"
|
||||
title,
|
||||
});
|
||||
scheduleSave(selectedNote.id, { title });
|
||||
}}
|
||||
className="min-w-0 flex-1 text-xl font-semibold outline-none"
|
||||
placeholder="Note title"
|
||||
/>
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground" role="status">{saveStatus}</span>
|
||||
<AlertDialog open={noteToDelete?.id === selectedNote.id} onOpenChange={(open) => !open && setNoteToDelete(null)}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" aria-label={`Delete ${selectedNote.title}`} onClick={() => setNoteToDelete(selectedNote)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader><AlertDialogTitle>Delete {noteToDelete?.title}?</AlertDialogTitle><AlertDialogDescription>This permanently deletes this note.</AlertDialogDescription></AlertDialogHeader>
|
||||
<AlertDialogFooter><AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel><AlertDialogAction onClick={deleteNote} disabled={deleting}>{deleting ? 'Deleting...' : 'Delete note'}</AlertDialogAction></AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<Suspense
|
||||
@@ -264,14 +366,7 @@ export default function NotesPage() {
|
||||
>
|
||||
<NoteEditor
|
||||
content={selectedNote.content}
|
||||
onChange={(content) =>
|
||||
setSelectedNote({ ...selectedNote, content })
|
||||
}
|
||||
onBlur={() =>
|
||||
updateNote(selectedNote.id, {
|
||||
content: selectedNote.content,
|
||||
})
|
||||
}
|
||||
onChange={(content) => { setSelectedNote({ ...selectedNote, content }); scheduleSave(selectedNote.id, { content }); }}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
@@ -286,7 +381,7 @@ export default function NotesPage() {
|
||||
</Card>
|
||||
|
||||
{/* Backlinks and graph */}
|
||||
<Card className="h-[calc(100vh-200px)]">
|
||||
<Card className="min-h-[360px] lg:h-[calc(100vh-200px)]">
|
||||
<Tabs defaultValue="backlinks" className="h-full">
|
||||
<div className="border-b p-2">
|
||||
<TabsList className="w-full">
|
||||
@@ -302,7 +397,7 @@ export default function NotesPage() {
|
||||
</div>
|
||||
|
||||
<TabsContent value="backlinks" className="h-full overflow-auto p-4">
|
||||
{backlinks.length === 0 ? (
|
||||
{backlinksLoading ? <p className="py-8 text-center text-sm text-muted-foreground" role="status">Loading backlinks...</p> : backlinksError ? <div className="py-8 text-center"><p className="text-sm text-muted-foreground" role="alert">{backlinksError}</p><Button className="mt-3" size="sm" onClick={() => selectedNote && fetchBacklinks(selectedNote.id)}>Retry</Button></div> : backlinks.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
No backlinks
|
||||
</p>
|
||||
@@ -311,10 +406,7 @@ export default function NotesPage() {
|
||||
{backlinks.map((link) => (
|
||||
<button
|
||||
key={link.id}
|
||||
onClick={() => {
|
||||
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"
|
||||
>
|
||||
|
||||
@@ -162,7 +162,7 @@ export default function ProjectDetailPage() {
|
||||
</div>
|
||||
<CheckCircle2 className="h-8 w-8 text-green-600" aria-hidden="true" />
|
||||
</div>
|
||||
<Progress value={project.progress} className="mt-2 h-2" />
|
||||
<Progress value={project.progress} className="mt-2 h-2" aria-label={`${project.name} progress: ${project.progress}%`} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -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}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<Project[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 <p className="text-muted-foreground">Loading projects...</p>;
|
||||
return <p role="status" className="text-muted-foreground">Loading projects...</p>;
|
||||
}
|
||||
|
||||
const activeProjects = projects.filter((p) => p.status === 'active');
|
||||
@@ -57,12 +72,19 @@ export default function ProjectsPage() {
|
||||
<h1 className="text-2xl font-bold">Projects</h1>
|
||||
<p className="mt-1 text-muted-foreground">Every outcome has a home.</p>
|
||||
</div>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
New project
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div role="alert" className="mb-6 flex items-center justify-between gap-4 rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
<span>{error}</span>
|
||||
<Button variant="outline" size="sm" onClick={fetchProjects}>Retry</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active projects */}
|
||||
{activeProjects.length > 0 && (
|
||||
<section className="mb-8">
|
||||
@@ -107,9 +129,16 @@ export default function ProjectsPage() {
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Create your first project to get started
|
||||
</p>
|
||||
<Button className="mt-4" onClick={() => setCreateOpen(true)}>Create a project</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<CreateItemDialog
|
||||
type="project"
|
||||
open={createOpen}
|
||||
onOpenChange={handleCreateOpenChange}
|
||||
onCreated={fetchProjects}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -149,7 +178,7 @@ function ProjectCard({ project }: { project: Project }) {
|
||||
<span className="text-muted-foreground">Progress</span>
|
||||
<span className="font-semibold">{project.progress}%</span>
|
||||
</div>
|
||||
<Progress value={project.progress} className="h-2" />
|
||||
<Progress value={project.progress} className="h-2" aria-label={`${project.name} progress: ${project.progress}%`} />
|
||||
</div>
|
||||
|
||||
{/* Task count */}
|
||||
|
||||
@@ -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<Report | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showTemplates, setShowTemplates] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saveStatus, setSaveStatus] = useState<'Saved' | 'Saving' | 'Failed'>('Saved');
|
||||
const [reportToDelete, setReportToDelete] = useState<Report | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const pendingSave = useRef<{ id: string; updates: Partial<Report> } | null>(null);
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | 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<Report>) {
|
||||
function scheduleSave(reportId: string, updates: Partial<Report>) {
|
||||
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<Report>, 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 <p className="text-muted-foreground">Loading reports...</p>;
|
||||
return <p className="text-muted-foreground" role="status">Loading reports...</p>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="space-y-3"><p className="text-muted-foreground" role="alert">{error}</p><Button onClick={fetchReports}>Retry</Button></div>;
|
||||
}
|
||||
|
||||
if (showTemplates) {
|
||||
@@ -171,7 +228,7 @@ export default function ReportsPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Reports</h1>
|
||||
<p className="mt-1 text-muted-foreground">Step back and see what changed.</p>
|
||||
@@ -189,12 +246,16 @@ export default function ReportsPage() {
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[300px_1fr]">
|
||||
{/* Reports list */}
|
||||
<Card className="h-[calc(100vh-200px)] overflow-auto">
|
||||
<Card className="max-h-80 overflow-auto lg:h-[calc(100vh-200px)] lg:max-h-none">
|
||||
<div className="p-2">
|
||||
{reports.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
No reports yet
|
||||
</p>
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">No reports yet</p>
|
||||
<Button className="mt-3" size="sm" onClick={() => createReport()}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create your first report
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{reports.map((report) => (
|
||||
@@ -236,26 +297,35 @@ export default function ReportsPage() {
|
||||
</Card>
|
||||
|
||||
{/* Report editor */}
|
||||
<Card className="h-[calc(100vh-200px)]">
|
||||
<Card className="min-h-[420px] lg:h-[calc(100vh-200px)]">
|
||||
{selectedReport ? (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b p-4">
|
||||
<label htmlFor="report-title" className="sr-only">
|
||||
Report title
|
||||
</label>
|
||||
<input
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
id="report-title"
|
||||
type="text"
|
||||
value={selectedReport.title}
|
||||
onChange={(e) =>
|
||||
setSelectedReport({ ...selectedReport, title: e.target.value })
|
||||
}
|
||||
onBlur={() =>
|
||||
updateReport(selectedReport.id, { title: selectedReport.title })
|
||||
}
|
||||
className="w-full text-xl font-semibold outline-none"
|
||||
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"
|
||||
/>
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground" role="status">{saveStatus}</span>
|
||||
<AlertDialog open={reportToDelete?.id === selectedReport.id} onOpenChange={(open) => !open && setReportToDelete(null)}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" aria-label={`Delete ${selectedReport.title}`} onClick={() => setReportToDelete(selectedReport)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader><AlertDialogTitle>Delete {reportToDelete?.title}?</AlertDialogTitle><AlertDialogDescription>This permanently deletes this report.</AlertDialogDescription></AlertDialogHeader>
|
||||
<AlertDialogFooter><AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel><AlertDialogAction onClick={deleteReport} disabled={deleting}>{deleting ? 'Deleting...' : 'Delete report'}</AlertDialogAction></AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Badge variant="outline">{selectedReport.report_type}</Badge>
|
||||
<Badge variant="outline">{selectedReport.domain}</Badge>
|
||||
@@ -280,12 +350,7 @@ export default function ReportsPage() {
|
||||
>
|
||||
<ReportEditor
|
||||
content={selectedReport.content}
|
||||
onChange={(content) =>
|
||||
setSelectedReport({ ...selectedReport, content })
|
||||
}
|
||||
onBlur={() =>
|
||||
updateReport(selectedReport.id, { content: selectedReport.content })
|
||||
}
|
||||
onChange={(content) => { setSelectedReport({ ...selectedReport, content }); scheduleSave(selectedReport.id, { content }); }}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
@@ -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<ErrorLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<string | null>(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() {
|
||||
</CardDescription>
|
||||
</div>
|
||||
{errors.length > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={clearErrors} aria-label="Clear all error logs">
|
||||
<Trash2 className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
Clear all
|
||||
</Button>
|
||||
<AlertDialog open={confirmClear} onOpenChange={setConfirmClear}>
|
||||
<Button variant="outline" size="sm" onClick={() => setConfirmClear(true)} aria-label="Clear all error logs">
|
||||
<Trash2 className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
Clear all
|
||||
</Button>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Clear all error logs?</AlertDialogTitle>
|
||||
<AlertDialogDescription>This permanently removes all displayed error logs.</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={clearing}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={clearErrors} disabled={clearing}>
|
||||
{clearing ? 'Clearing...' : 'Clear all'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{error && (
|
||||
<div className="mb-4 flex items-center justify-between gap-3 text-sm text-destructive" role="alert">
|
||||
<span>{error}</span>
|
||||
<Button variant="outline" size="sm" onClick={fetchErrors} disabled={loading}>Retry</Button>
|
||||
</div>
|
||||
)}
|
||||
{status && <p className="mb-4 text-sm text-muted-foreground" role="status">{status}</p>}
|
||||
{errors.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground py-8">
|
||||
No errors logged
|
||||
|
||||
@@ -26,39 +26,39 @@ export default function SettingsPage() {
|
||||
<p className="mt-1 text-muted-foreground">Tune Project E to fit your work.</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="appearance" orientation="vertical" className="flex gap-6">
|
||||
<TabsList className="flex w-[200px] flex-col gap-1 bg-transparent h-auto">
|
||||
<TabsTrigger value="appearance" className="justify-start gap-2">
|
||||
<Tabs defaultValue="appearance" orientation="vertical" className="flex flex-col gap-6 md:flex-row">
|
||||
<TabsList className="flex h-auto w-full flex-row justify-start gap-1 overflow-x-auto bg-transparent p-0 md:w-[200px] md:flex-col">
|
||||
<TabsTrigger value="appearance" className="shrink-0 justify-start gap-2">
|
||||
<Palette className="h-4 w-4" aria-hidden="true" />
|
||||
Appearance
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="domains" className="justify-start gap-2">
|
||||
<TabsTrigger value="domains" className="shrink-0 justify-start gap-2">
|
||||
<Globe className="h-4 w-4" aria-hidden="true" />
|
||||
Domains
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="shortcuts" className="justify-start gap-2">
|
||||
<TabsTrigger value="shortcuts" className="shrink-0 justify-start gap-2">
|
||||
<Keyboard className="h-4 w-4" aria-hidden="true" />
|
||||
Keyboard Shortcuts
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="agents" className="justify-start gap-2">
|
||||
<TabsTrigger value="agents" className="shrink-0 justify-start gap-2">
|
||||
<Bot className="h-4 w-4" aria-hidden="true" />
|
||||
Agents & Permissions
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="webhooks" className="justify-start gap-2">
|
||||
<TabsTrigger value="webhooks" className="shrink-0 justify-start gap-2">
|
||||
<Webhook className="h-4 w-4" aria-hidden="true" />
|
||||
Webhooks
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="import-export" className="justify-start gap-2">
|
||||
<TabsTrigger value="import-export" className="shrink-0 justify-start gap-2">
|
||||
<Download className="h-4 w-4" aria-hidden="true" />
|
||||
Import & Export
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="error-log" className="justify-start gap-2">
|
||||
<TabsTrigger value="error-log" className="shrink-0 justify-start gap-2">
|
||||
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
|
||||
Error Log
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<TabsContent value="appearance">
|
||||
<SettingsAppearance />
|
||||
</TabsContent>
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
@@ -20,6 +34,10 @@ export default function TasksPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
New task
|
||||
</Button>
|
||||
<Tabs
|
||||
value={view}
|
||||
onValueChange={(v) => setView(v as 'kanban' | 'list')}
|
||||
@@ -38,7 +56,17 @@ export default function TasksPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view === 'kanban' ? <TasksKanbanView /> : <TasksListView />}
|
||||
{view === 'kanban' ? (
|
||||
<TasksKanbanView key={refreshKey} />
|
||||
) : (
|
||||
<TasksListView key={refreshKey} />
|
||||
)}
|
||||
<CreateItemDialog
|
||||
type="task"
|
||||
open={createOpen}
|
||||
onOpenChange={handleCreateOpenChange}
|
||||
onCreated={() => setRefreshKey((key) => key + 1)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,13 +15,19 @@ export const GET = withAuth(async (request: NextRequest, _user) => {
|
||||
});
|
||||
|
||||
const byDomain: Record<string, number> = {};
|
||||
const byDate: Record<string, number> = {};
|
||||
const byProject: Record<string, number> = {};
|
||||
const byTag: Record<string, number> = {};
|
||||
let totalMinutes = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
const duration = (entry.duration_minutes as number) || 0;
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user