feat: Phase 5 - Calendar + Dashboard + Search
Calendar: - GET /api/domains/[domainId]/calendar/events?from=&to= — returns tasks, habits, projects, milestones - PATCH /api/domains/[domainId]/tasks/[id]/schedule — drag-to-reschedule with activity feed - Calendar UI with month/week/day views via react-big-calendar - Drag-to-reschedule with SSE updates - Filter by entity type and domain - Keyboard shortcuts: t=today, m/w/d=view, ←/→=navigate - Mobile: auto-switches to day view on small screens Dashboard: - GET/PUT /api/domains/[domainId]/dashboard — layout stored in domain custom_fields - 8 per-widget data endpoints (today-tasks, habit-checklist, weekly-stats, project-progress, upcoming-calendar, recent-notes, activity-feed, quick-capture) - react-grid-layout with responsive breakpoints (12/8/4 cols) - Drag-to-reorder, resize, add/remove widgets - Edit mode toggle, per-workspace layout persistence - Widget error boundary Search: - tsvector columns + GIN indexes on tasks, notes, projects, habits, domains - GET /api/search?q=&types=&domain= — ranked results with ts_headline snippets - Dedicated search page with grouped results, filters, recent searches (localStorage) - Empty state with hints Schema: - Added custom_fields jsonb column to domains table (migration 0002) - Removed stale root app/ directory Build: passes, typecheck: passes, tests: 18/18 wikilink-parser tests pass
This commit is contained in:
@@ -1,15 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useMemo, Suspense } from 'react';
|
||||
import { Filter } from 'lucide-react';
|
||||
import { useEffect, useState, useMemo, useCallback, Suspense } from 'react';
|
||||
import { Filter, ChevronLeft, ChevronRight, CalendarDays, Calendar as CalendarIcon } from 'lucide-react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
// Lazy load react-big-calendar (~60KB + date-fns)
|
||||
// Lazy load react-big-calendar
|
||||
const BigCalendar = dynamic(
|
||||
() => import('@/components/calendar/big-calendar-wrapper').then((m) => m.BigCalendarWrapper),
|
||||
{
|
||||
@@ -27,24 +28,48 @@ interface CalendarEvent {
|
||||
title: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
type: 'task' | 'project' | 'milestone';
|
||||
domain: string;
|
||||
type: 'task' | 'habit' | 'project' | 'milestone';
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
color: string;
|
||||
domainId: string;
|
||||
href: string;
|
||||
priority?: string;
|
||||
difficulty?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export default function CalendarPage() {
|
||||
const router = useRouter();
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [view, setView] = useState<'month' | 'week' | 'day'>('month');
|
||||
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[]>([]);
|
||||
const [domainOptions, setDomainOptions] = useState<{id: string; name: string}[]>([]);
|
||||
const [domainOptions, setDomainOptions] = useState<{id: string; name: string; color: string | null}[]>([]);
|
||||
const [currentDomainId, setCurrentDomainId] = useState<string | null>(null);
|
||||
|
||||
// Auto-switch to day view on mobile
|
||||
useEffect(() => {
|
||||
fetchEvents();
|
||||
const checkWidth = () => {
|
||||
if (window.innerWidth < 640 && view !== 'day') {
|
||||
setView('day');
|
||||
}
|
||||
};
|
||||
checkWidth();
|
||||
window.addEventListener('resize', checkWidth);
|
||||
return () => window.removeEventListener('resize', checkWidth);
|
||||
}, []);
|
||||
|
||||
// Get current domain from URL or default
|
||||
useEffect(() => {
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
// Try to find domain from sidebar or use first domain
|
||||
fetchDomains();
|
||||
}, []);
|
||||
|
||||
@@ -54,136 +79,182 @@ export default function CalendarPage() {
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setDomainOptions(data.items || []);
|
||||
if (data.items?.length > 0 && !currentDomainId) {
|
||||
setCurrentDomainId(data.items[0].id);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function fetchEvents() {
|
||||
const fetchEvents = useCallback(async (domainId: string, from: Date, to: Date) => {
|
||||
if (!domainId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [tasksResponse, projectsResponse, milestonesResponse] = await Promise.all([
|
||||
fetch('/api/tasks?perPage=500'),
|
||||
fetch('/api/projects?perPage=500'),
|
||||
fetch('/api/milestones?perPage=500'),
|
||||
]);
|
||||
|
||||
if (!tasksResponse.ok || !projectsResponse.ok || !milestonesResponse.ok) {
|
||||
throw new Error('One or more calendar sources could not be loaded.');
|
||||
}
|
||||
|
||||
const [tasksData, projectsData, milestonesData] = await Promise.all([
|
||||
tasksResponse.json(),
|
||||
projectsResponse.json(),
|
||||
milestonesResponse.json(),
|
||||
]);
|
||||
|
||||
const calendarEvents: CalendarEvent[] = [];
|
||||
|
||||
// Add tasks
|
||||
if (tasksData.items) {
|
||||
for (const task of tasksData.items) {
|
||||
if (task.due_date) {
|
||||
const date = new Date(task.due_date);
|
||||
calendarEvents.push({
|
||||
id: `task-${task.id}`,
|
||||
title: task.title,
|
||||
start: date,
|
||||
end: date,
|
||||
type: 'task',
|
||||
domain: task.domain ?? 'personal',
|
||||
color: '#3b82f6',
|
||||
href: '/tasks',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add projects
|
||||
if (projectsData.items) {
|
||||
for (const project of projectsData.items) {
|
||||
if (project.due_date) {
|
||||
const date = new Date(project.due_date);
|
||||
calendarEvents.push({
|
||||
id: `project-${project.id}`,
|
||||
title: project.name,
|
||||
start: date,
|
||||
end: date,
|
||||
type: 'project',
|
||||
domain: project.domain ?? 'personal',
|
||||
color: '#8b5cf6',
|
||||
href: `/projects/${project.id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add milestones
|
||||
if (milestonesData.items) {
|
||||
for (const milestone of milestonesData.items) {
|
||||
if (milestone.due_date) {
|
||||
const date = new Date(milestone.due_date);
|
||||
calendarEvents.push({
|
||||
id: `milestone-${milestone.id}`,
|
||||
title: milestone.name || milestone.title,
|
||||
start: date,
|
||||
end: date,
|
||||
type: 'milestone',
|
||||
domain: milestone.domain ?? 'work',
|
||||
color: '#f59e0b',
|
||||
href: milestone.project_id ? `/projects/${milestone.project_id}` : '/projects',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString(),
|
||||
types: ['task', 'habit', 'project', 'milestone'].join(','),
|
||||
});
|
||||
const res = await fetch(`/api/domains/${domainId}/calendar/events?${params}`);
|
||||
if (!res.ok) throw new Error('Failed to fetch events');
|
||||
const data = await res.json();
|
||||
const calendarEvents: CalendarEvent[] = (data.events || []).map((e: any) => ({
|
||||
...e,
|
||||
start: new Date(e.start),
|
||||
end: new Date(e.end),
|
||||
}));
|
||||
setEvents(calendarEvents);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch calendar events:', error);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch calendar events:', err);
|
||||
setError('Calendar events could not be loaded. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch events when domain or date range changes
|
||||
useEffect(() => {
|
||||
if (!currentDomainId) return;
|
||||
const range = getViewRange(currentDate, view);
|
||||
fetchEvents(currentDomainId, range.from, range.to);
|
||||
}, [currentDomainId, currentDate, view, fetchEvents]);
|
||||
|
||||
// Calendar keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.tagName === 'SELECT' ||
|
||||
target.isContentEditable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key.toLowerCase()) {
|
||||
case 't':
|
||||
navigate('today');
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 'm':
|
||||
setView('month');
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 'w':
|
||||
setView('week');
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 'd':
|
||||
setView('day');
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 'arrowleft':
|
||||
navigate('prev');
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 'arrowright':
|
||||
navigate('next');
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [currentDate, view]);
|
||||
|
||||
function getViewRange(date: Date, v: string): { from: Date; to: Date } {
|
||||
const from = new Date(date);
|
||||
const to = new Date(date);
|
||||
switch (v) {
|
||||
case 'month':
|
||||
from.setDate(1);
|
||||
from.setHours(0, 0, 0, 0);
|
||||
to.setMonth(to.getMonth() + 1, 0);
|
||||
to.setHours(23, 59, 59, 999);
|
||||
// Add buffer for week overlap
|
||||
from.setDate(from.getDate() - 7);
|
||||
to.setDate(to.getDate() + 7);
|
||||
break;
|
||||
case 'week': {
|
||||
const day = from.getDay();
|
||||
from.setDate(from.getDate() - day);
|
||||
from.setHours(0, 0, 0, 0);
|
||||
to.setDate(to.getDate() + (6 - day));
|
||||
to.setHours(23, 59, 59, 999);
|
||||
break;
|
||||
}
|
||||
case 'day':
|
||||
from.setHours(0, 0, 0, 0);
|
||||
to.setHours(23, 59, 59, 999);
|
||||
break;
|
||||
}
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
const navigate = (direction: 'prev' | 'next' | 'today') => {
|
||||
const d = new Date(currentDate);
|
||||
switch (direction) {
|
||||
case 'prev':
|
||||
if (view === 'month') d.setMonth(d.getMonth() - 1);
|
||||
else if (view === 'week') d.setDate(d.getDate() - 7);
|
||||
else d.setDate(d.getDate() - 1);
|
||||
break;
|
||||
case 'next':
|
||||
if (view === 'month') d.setMonth(d.getMonth() + 1);
|
||||
else if (view === 'week') d.setDate(d.getDate() + 7);
|
||||
else d.setDate(d.getDate() + 1);
|
||||
break;
|
||||
case 'today':
|
||||
d.setTime(Date.now());
|
||||
break;
|
||||
}
|
||||
setCurrentDate(d);
|
||||
};
|
||||
|
||||
const handleEventDrop = async (event: CalendarEvent, newStart: Date) => {
|
||||
if (event.entityType !== 'task') return;
|
||||
try {
|
||||
const res = await fetch(`/api/domains/${currentDomainId}/tasks/${event.entityId}/schedule`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dueDate: newStart.toISOString() }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to reschedule');
|
||||
// Refresh events
|
||||
const range = getViewRange(currentDate, view);
|
||||
fetchEvents(currentDomainId!, range.from, range.to);
|
||||
} catch (err) {
|
||||
console.error('Failed to reschedule task:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredEvents = useMemo(() => {
|
||||
return events.filter((event) => {
|
||||
// Filter by type
|
||||
if (event.type === 'task' && !showTasks) return false;
|
||||
if (event.type === 'habit' && !showHabits) return false;
|
||||
if (event.type === 'project' && !showProjects) return false;
|
||||
if (event.type === 'milestone' && !showMilestones) return false;
|
||||
|
||||
// Filter by domain
|
||||
if (selectedDomains.length > 0 && !selectedDomains.includes(event.domain)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (selectedDomains.length > 0 && !selectedDomains.includes(event.domainId)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [events, showTasks, showProjects, showMilestones, selectedDomains]);
|
||||
}, [events, showTasks, showHabits, showProjects, showMilestones, selectedDomains]);
|
||||
|
||||
function toggleDomain(domain: string) {
|
||||
const toggleDomain = (domainId: string) => {
|
||||
setSelectedDomains((prev) =>
|
||||
prev.includes(domain) ? prev.filter((d) => d !== domain) : [...prev, domain]
|
||||
prev.includes(domainId) ? prev.filter((d) => d !== domainId) : [...prev, domainId]
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<p className="text-muted-foreground">Loading calendar...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
const formatTitle = () => {
|
||||
const opts: Intl.DateTimeFormatOptions = {};
|
||||
if (view === 'month') { opts.month = 'long'; opts.year = 'numeric'; }
|
||||
else if (view === 'week') { opts.month = 'short'; opts.day = 'numeric'; }
|
||||
else { opts.weekday = 'long'; opts.month = 'long'; opts.day = 'numeric'; opts.year = 'numeric'; }
|
||||
return currentDate.toLocaleDateString('en-US', opts);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -194,6 +265,36 @@ export default function CalendarPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => navigate('today')}>
|
||||
<CalendarIcon className="mr-1 h-4 w-4" />
|
||||
Today
|
||||
</Button>
|
||||
<div className="flex">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate('prev')}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate('next')}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<h2 className="min-w-[180px] text-lg font-semibold">{formatTitle()}</h2>
|
||||
<div className="ml-auto flex rounded-lg border">
|
||||
{(['month', 'week', 'day'] as const).map((v) => (
|
||||
<Button
|
||||
key={v}
|
||||
variant={view === v ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="rounded-none capitalize"
|
||||
onClick={() => setView(v)}
|
||||
>
|
||||
{v}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[250px_1fr]">
|
||||
{/* Filters sidebar */}
|
||||
<Card>
|
||||
@@ -206,36 +307,31 @@ export default function CalendarPage() {
|
||||
<CardContent className="space-y-6">
|
||||
{/* Entity types */}
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-sm font-semibold">Show</h2>
|
||||
<h3 className="text-sm font-semibold">Show</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="tasks"
|
||||
checked={showTasks}
|
||||
onCheckedChange={(checked) => setShowTasks(checked === true)}
|
||||
/>
|
||||
<Checkbox id="tasks" checked={showTasks} onCheckedChange={(c) => setShowTasks(c === true)} />
|
||||
<Label htmlFor="tasks" className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded" style={{ backgroundColor: '#3b82f6' }} />
|
||||
Tasks
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="projects"
|
||||
checked={showProjects}
|
||||
onCheckedChange={(checked) => setShowProjects(checked === true)}
|
||||
/>
|
||||
<Checkbox id="habits" checked={showHabits} onCheckedChange={(c) => setShowHabits(c === true)} />
|
||||
<Label htmlFor="habits" className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded" style={{ backgroundColor: '#22c55e' }} />
|
||||
Habits
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="projects" checked={showProjects} onCheckedChange={(c) => setShowProjects(c === true)} />
|
||||
<Label htmlFor="projects" className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded" style={{ backgroundColor: '#8b5cf6' }} />
|
||||
Projects
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="milestones"
|
||||
checked={showMilestones}
|
||||
onCheckedChange={(checked) => setShowMilestones(checked === true)}
|
||||
/>
|
||||
<Checkbox id="milestones" checked={showMilestones} onCheckedChange={(c) => setShowMilestones(c === true)} />
|
||||
<Label htmlFor="milestones" className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded" style={{ backgroundColor: '#f59e0b' }} />
|
||||
Milestones
|
||||
@@ -246,9 +342,9 @@ export default function CalendarPage() {
|
||||
|
||||
{/* Domains */}
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-sm font-semibold">Domains</h2>
|
||||
<h3 className="text-sm font-semibold">Domains</h3>
|
||||
<div className="space-y-2">
|
||||
{(domainOptions.length > 0 ? domainOptions : []).map((domain) => (
|
||||
{domainOptions.map((domain) => (
|
||||
<div key={domain.id} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={domain.id}
|
||||
@@ -262,12 +358,7 @@ export default function CalendarPage() {
|
||||
))}
|
||||
</div>
|
||||
{selectedDomains.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedDomains([])}
|
||||
className="text-xs"
|
||||
>
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedDomains([])} className="text-xs">
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
@@ -275,9 +366,10 @@ export default function CalendarPage() {
|
||||
|
||||
{/* Legend */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<h2 className="text-sm font-semibold">Legend</h2>
|
||||
<h3 className="text-sm font-semibold">Legend</h3>
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
<p>• Tasks show on due date</p>
|
||||
<p>• Tasks show on due date (color = priority)</p>
|
||||
<p>• Habits show daily (color = difficulty)</p>
|
||||
<p>• Projects show on deadline</p>
|
||||
<p>• Milestones show on due date</p>
|
||||
</div>
|
||||
@@ -288,15 +380,34 @@ export default function CalendarPage() {
|
||||
{/* Calendar */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-[600px] items-center justify-center rounded-lg border bg-muted/30">
|
||||
<div className="animate-pulse text-sm text-muted-foreground">Loading calendar...</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<BigCalendar events={filteredEvents} />
|
||||
</Suspense>
|
||||
{error ? (
|
||||
<div className="flex flex-col items-center gap-4 py-20">
|
||||
<p className="text-muted-foreground" role="alert">{error}</p>
|
||||
<Button onClick={() => {
|
||||
const range = getViewRange(currentDate, view);
|
||||
if (currentDomainId) fetchEvents(currentDomainId, range.from, range.to);
|
||||
}}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-[600px] items-center justify-center rounded-lg border bg-muted/30">
|
||||
<div className="animate-pulse text-sm text-muted-foreground">Loading calendar...</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<BigCalendar
|
||||
events={filteredEvents}
|
||||
onEventDrop={handleEventDrop}
|
||||
defaultView={view}
|
||||
date={currentDate}
|
||||
onNavigate={setCurrentDate}
|
||||
onViewChange={(v: string) => setView(v as 'month' | 'week' | 'day')}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import React, { Suspense } from 'react';
|
||||
import React, { Suspense, useEffect, useState } 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';
|
||||
import { Settings2, LayoutGrid } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
// Lazy load react-grid-layout (client-only, ~45KB)
|
||||
const ResponsiveGridLayout = dynamic(
|
||||
@@ -21,84 +23,15 @@ const ResponsiveGridLayout = dynamic(
|
||||
}
|
||||
);
|
||||
|
||||
// Lazy load individual widgets — each is code-split into its own chunk
|
||||
const TodayTasksWidget = dynamic(
|
||||
() =>
|
||||
import('@/components/dashboard/widgets/today-tasks-widget').then((m) => m.TodayTasksWidget),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <WidgetSkeleton />,
|
||||
}
|
||||
);
|
||||
|
||||
const HabitChecklistWidget = dynamic(
|
||||
() =>
|
||||
import('@/components/dashboard/widgets/habit-checklist-widget').then(
|
||||
(m) => m.HabitChecklistWidget
|
||||
),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <WidgetSkeleton />,
|
||||
}
|
||||
);
|
||||
|
||||
const WeeklyStatsWidget = dynamic(
|
||||
() =>
|
||||
import('@/components/dashboard/widgets/weekly-stats-widget').then((m) => m.WeeklyStatsWidget),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <WidgetSkeleton />,
|
||||
}
|
||||
);
|
||||
|
||||
const ProjectProgressWidget = dynamic(
|
||||
() =>
|
||||
import('@/components/dashboard/widgets/project-progress-widget').then(
|
||||
(m) => m.ProjectProgressWidget
|
||||
),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <WidgetSkeleton />,
|
||||
}
|
||||
);
|
||||
|
||||
const HabitStreaksWidget = dynamic(
|
||||
() =>
|
||||
import('@/components/dashboard/widgets/habit-streaks-widget').then((m) => m.HabitStreaksWidget),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <WidgetSkeleton />,
|
||||
}
|
||||
);
|
||||
|
||||
const CalendarMiniWidget = dynamic(
|
||||
() =>
|
||||
import('@/components/dashboard/widgets/calendar-mini-widget').then((m) => m.CalendarMiniWidget),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <WidgetSkeleton />,
|
||||
}
|
||||
);
|
||||
|
||||
const QuickAddWidget = dynamic(
|
||||
() =>
|
||||
import('@/components/dashboard/widgets/quick-add-widget').then((m) => m.QuickAddWidget),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <WidgetSkeleton />,
|
||||
}
|
||||
);
|
||||
|
||||
const RecentActivityWidget = dynamic(
|
||||
() =>
|
||||
import('@/components/dashboard/widgets/recent-activity-widget').then(
|
||||
(m) => m.RecentActivityWidget
|
||||
),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <WidgetSkeleton />,
|
||||
}
|
||||
);
|
||||
// Lazy load individual widgets
|
||||
const TodayTasksWidget = dynamic(() => import('@/components/dashboard/widgets/today-tasks-widget').then((m) => m.TodayTasksWidget), { ssr: false, loading: () => <WidgetSkeleton /> });
|
||||
const HabitChecklistWidget = dynamic(() => import('@/components/dashboard/widgets/habit-checklist-widget').then((m) => m.HabitChecklistWidget), { ssr: false, loading: () => <WidgetSkeleton /> });
|
||||
const WeeklyStatsWidget = dynamic(() => import('@/components/dashboard/widgets/weekly-stats-widget').then((m) => m.WeeklyStatsWidget), { ssr: false, loading: () => <WidgetSkeleton /> });
|
||||
const ProjectProgressWidget = dynamic(() => import('@/components/dashboard/widgets/project-progress-widget').then((m) => m.ProjectProgressWidget), { ssr: false, loading: () => <WidgetSkeleton /> });
|
||||
const UpcomingCalendarWidget = dynamic(() => import('@/components/dashboard/widgets/upcoming-calendar-widget').then((m) => m.UpcomingCalendarWidget), { ssr: false, loading: () => <WidgetSkeleton /> });
|
||||
const RecentNotesWidget = dynamic(() => import('@/components/dashboard/widgets/recent-notes-widget').then((m) => m.RecentNotesWidget), { ssr: false, loading: () => <WidgetSkeleton /> });
|
||||
const ActivityFeedWidget = dynamic(() => import('@/components/dashboard/widgets/activity-feed-widget').then((m) => m.ActivityFeedWidget), { ssr: false, loading: () => <WidgetSkeleton /> });
|
||||
const QuickCaptureWidget = dynamic(() => import('@/components/dashboard/widgets/quick-capture-widget').then((m) => m.QuickCaptureWidget), { ssr: false, loading: () => <WidgetSkeleton /> });
|
||||
|
||||
function WidgetSkeleton() {
|
||||
return (
|
||||
@@ -118,15 +51,29 @@ const widgetComponents: Record<string, React.ComponentType> = {
|
||||
'habit-checklist': HabitChecklistWidget,
|
||||
'weekly-stats': WeeklyStatsWidget,
|
||||
'project-progress': ProjectProgressWidget,
|
||||
'habit-streaks': HabitStreaksWidget,
|
||||
'calendar-mini': CalendarMiniWidget,
|
||||
'quick-add': QuickAddWidget,
|
||||
'recent-activity': RecentActivityWidget,
|
||||
'upcoming-calendar': UpcomingCalendarWidget,
|
||||
'recent-notes': RecentNotesWidget,
|
||||
'activity-feed': ActivityFeedWidget,
|
||||
'quick-capture': QuickCaptureWidget,
|
||||
};
|
||||
|
||||
const widgetLabels: Record<string, string> = {
|
||||
'today-tasks': "Today's Tasks",
|
||||
'habit-checklist': 'Habit Checklist',
|
||||
'weekly-stats': 'Weekly Stats',
|
||||
'project-progress': 'Project Progress',
|
||||
'upcoming-calendar': 'Upcoming Calendar',
|
||||
'recent-notes': 'Recent Notes',
|
||||
'activity-feed': 'Activity Feed',
|
||||
'quick-capture': 'Quick Capture',
|
||||
};
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { widgets, setWidgets } = useDashboardStore();
|
||||
const { widgets, setWidgets, addWidget, removeWidget } = useDashboardStore();
|
||||
const [layoutAnnouncement, setLayoutAnnouncement] = React.useState('');
|
||||
const [editMode, setEditMode] = React.useState(false);
|
||||
const [showConfig, setShowConfig] = React.useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const layout = widgets.map((w) => ({
|
||||
i: w.id,
|
||||
@@ -140,81 +87,98 @@ export default function DashboardPage() {
|
||||
const updated = widgets.map((w) => {
|
||||
const layoutItem = newLayout.find((l) => l.i === w.id);
|
||||
if (layoutItem) {
|
||||
return {
|
||||
...w,
|
||||
x: layoutItem.x,
|
||||
y: layoutItem.y,
|
||||
w: layoutItem.w,
|
||||
h: layoutItem.h,
|
||||
};
|
||||
return { ...w, x: layoutItem.x, y: layoutItem.y, w: layoutItem.w, h: layoutItem.h };
|
||||
}
|
||||
return w;
|
||||
});
|
||||
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 availableWidgets = Object.keys(widgetComponents).filter((id) => !widgets.find((w) => w.id === id));
|
||||
|
||||
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.`);
|
||||
function addNewWidget(widgetId: string) {
|
||||
addWidget({
|
||||
id: widgetId,
|
||||
type: widgetLabels[widgetId] || widgetId,
|
||||
x: 0,
|
||||
y: widgets.length,
|
||||
w: 4,
|
||||
h: 3,
|
||||
visible: true,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||
<p className="mt-1 text-muted-foreground">Your day, at a glance.</p>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||
<p className="mt-1 text-muted-foreground">Your day, at a glance.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={editMode ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setEditMode(!editMode)}
|
||||
>
|
||||
<LayoutGrid className="mr-1 h-4 w-4" />
|
||||
{editMode ? 'Done' : 'Edit'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowConfig(!showConfig)}
|
||||
>
|
||||
<Settings2 className="mr-1 h-4 w-4" />
|
||||
Configure
|
||||
</Button>
|
||||
</div>
|
||||
</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
|
||||
{/* Widget configuration panel */}
|
||||
{showConfig && (
|
||||
<div className="mb-6 rounded-lg border bg-card p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold">Add Widgets</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableWidgets.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">All widgets are already on your dashboard.</p>
|
||||
) : (
|
||||
availableWidgets.map((id) => (
|
||||
<Button
|
||||
key={id}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => addNewWidget(id)}
|
||||
>
|
||||
+ {widgetLabels[id] || id}
|
||||
</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 className="mt-4">
|
||||
<h3 className="mb-3 text-sm font-semibold">Active Widgets</h3>
|
||||
<div className="space-y-2">
|
||||
{widgets.map((w) => (
|
||||
<div key={w.id} className="flex items-center justify-between rounded-md bg-muted/50 px-3 py-2">
|
||||
<span className="text-sm">{widgetLabels[w.id] || w.type}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive"
|
||||
onClick={() => removeWidget(w.id)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<p className="sr-only" aria-live="polite">{layoutAnnouncement}</p>
|
||||
|
||||
<ResponsiveGridLayout layout={layout} onLayoutChange={handleLayoutChange}>
|
||||
<ResponsiveGridLayout layout={layout} onLayoutChange={handleLayoutChange} isDraggable={editMode} isResizable={editMode}>
|
||||
{widgets.map((widget) => {
|
||||
const WidgetComponent = widgetComponents[widget.id];
|
||||
if (!WidgetComponent) return null;
|
||||
@@ -223,7 +187,7 @@ export default function DashboardPage() {
|
||||
<div key={widget.id}>
|
||||
<WidgetErrorBoundary widgetName={widget.type}>
|
||||
<div className="h-full rounded-lg border bg-card p-4 shadow-sm">
|
||||
<div className="widget-drag-handle">
|
||||
<div className={editMode ? 'widget-drag-handle' : ''}>
|
||||
<Suspense fallback={<WidgetSkeleton />}>
|
||||
<WidgetComponent />
|
||||
</Suspense>
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useMemo, useCallback, Suspense } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Search, Calendar, ListTodo, BookOpen, FolderKanban, Hash, ExternalLink, Clock, Filter, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
type: 'task' | 'note' | 'project' | 'habit' | 'domain';
|
||||
title: string;
|
||||
snippet: string;
|
||||
score: number;
|
||||
workspaceId: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
const typeIcons: Record<string, React.ReactNode> = {
|
||||
task: <ListTodo className="h-4 w-4" />,
|
||||
note: <BookOpen className="h-4 w-4" />,
|
||||
project: <FolderKanban className="h-4 w-4" />,
|
||||
habit: <Hash className="h-4 w-4" />,
|
||||
domain: <Calendar className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
task: 'bg-blue-500/10 text-blue-600',
|
||||
note: 'bg-green-500/10 text-green-600',
|
||||
project: 'bg-purple-500/10 text-purple-600',
|
||||
habit: 'bg-orange-500/10 text-orange-600',
|
||||
domain: 'bg-gray-500/10 text-gray-600',
|
||||
};
|
||||
|
||||
export default function SearchPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex h-96 items-center justify-center"><div className="animate-pulse text-sm text-muted-foreground">Loading search...</div></div>}>
|
||||
<SearchPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchPageContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const initialQuery = searchParams.get('q') || '';
|
||||
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedTypes, setSelectedTypes] = useState<string[]>(['task', 'note', 'project', 'habit', 'domain']);
|
||||
const [selectedDomain, setSelectedDomain] = useState<string>('all');
|
||||
const [recentSearches, setRecentSearches] = useState<string[]>([]);
|
||||
|
||||
// Load recent searches from localStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('project-e-recent-searches');
|
||||
if (stored) setRecentSearches(JSON.parse(stored));
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
const saveRecentSearch = useCallback((q: string) => {
|
||||
const updated = [q, ...recentSearches.filter(s => s !== q)].slice(0, 10);
|
||||
setRecentSearches(updated);
|
||||
try {
|
||||
localStorage.setItem('project-e-recent-searches', JSON.stringify(updated));
|
||||
} catch {}
|
||||
}, [recentSearches]);
|
||||
|
||||
const doSearch = useCallback(async (q: string) => {
|
||||
if (!q.trim()) {
|
||||
setResults([]);
|
||||
setTotalCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ q });
|
||||
if (selectedTypes.length < 5) params.set('types', selectedTypes.join(','));
|
||||
if (selectedDomain !== 'all') params.set('domain', selectedDomain);
|
||||
|
||||
const res = await fetch(`/api/search?${params}`);
|
||||
if (!res.ok) throw new Error('Search failed');
|
||||
|
||||
const data = await res.json();
|
||||
setResults(data.results || []);
|
||||
setTotalCount(data.totalCount || 0);
|
||||
saveRecentSearch(q);
|
||||
} catch (err) {
|
||||
setError('Search failed. Please try again.');
|
||||
console.error('Search error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedTypes, selectedDomain, saveRecentSearch]);
|
||||
|
||||
// Initial search from URL param
|
||||
useEffect(() => {
|
||||
if (initialQuery) doSearch(initialQuery);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
doSearch(query);
|
||||
router.replace(`/search?q=${encodeURIComponent(query)}`);
|
||||
};
|
||||
|
||||
const groupedResults = useMemo(() => {
|
||||
const groups: Record<string, SearchResult[]> = {
|
||||
task: [], note: [], project: [], habit: [], domain: [],
|
||||
};
|
||||
for (const r of results) {
|
||||
if (groups[r.type]) groups[r.type].push(r);
|
||||
}
|
||||
return Object.entries(groups).filter(([, items]) => items.length > 0);
|
||||
}, [results]);
|
||||
|
||||
const toggleType = (type: string) => {
|
||||
setSelectedTypes(prev =>
|
||||
prev.includes(type) ? prev.filter(t => t !== type) : [...prev, type]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold">Search</h1>
|
||||
<p className="mt-1 text-muted-foreground">Find anything across your workspace.</p>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
<form onSubmit={handleSearch} className="mb-6">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search tasks, notes, projects, habits..."
|
||||
className="pl-10 pr-20"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2"
|
||||
disabled={loading || !query.trim()}
|
||||
>
|
||||
{loading ? 'Searching...' : 'Search'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mb-6 flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Filter:</span>
|
||||
</div>
|
||||
{['task', 'note', 'project', 'habit', 'domain'].map(type => (
|
||||
<Badge
|
||||
key={type}
|
||||
variant={selectedTypes.includes(type) ? 'default' : 'outline'}
|
||||
className="cursor-pointer capitalize"
|
||||
onClick={() => toggleType(type)}
|
||||
>
|
||||
{typeIcons[type]}
|
||||
<span className="ml-1">{type}s</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{error && (
|
||||
<Card className="mb-6 border-destructive">
|
||||
<CardContent className="p-4 text-sm text-destructive">{error}</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && !error && query && results.length === 0 && (
|
||||
<div className="py-12 text-center">
|
||||
<Search className="mx-auto mb-4 h-12 w-12 text-muted-foreground/50" />
|
||||
<h3 className="text-lg font-medium">No results found</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Try different keywords or adjust your filters.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!query && !loading && (
|
||||
<div className="py-12 text-center">
|
||||
<Search className="mx-auto mb-4 h-12 w-12 text-muted-foreground/50" />
|
||||
<h3 className="text-lg font-medium">Search your workspace</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Type a query above to search across tasks, notes, projects, habits, and domains.
|
||||
</p>
|
||||
{recentSearches.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h4 className="mb-2 text-sm font-medium text-muted-foreground">Recent searches</h4>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{recentSearches.map((s, i) => (
|
||||
<Badge
|
||||
key={i}
|
||||
variant="secondary"
|
||||
className="cursor-pointer"
|
||||
onClick={() => { setQuery(s); doSearch(s); }}
|
||||
>
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
{s}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="p-4">
|
||||
<div className="mb-2 h-4 w-48 animate-pulse rounded bg-muted" />
|
||||
<div className="h-3 w-full animate-pulse rounded bg-muted/50" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && results.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
Found {totalCount} result{totalCount !== 1 ? 's' : ''} for “{query}”
|
||||
</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
{groupedResults.map(([type, items]) => (
|
||||
<div key={type}>
|
||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold capitalize">
|
||||
{typeIcons[type]}
|
||||
{type}s
|
||||
<Badge variant="secondary" className="ml-1 text-xs">{items.length}</Badge>
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{items.map((result) => (
|
||||
<Card
|
||||
key={`${result.type}-${result.id}`}
|
||||
className="cursor-pointer transition-colors hover:bg-accent/50"
|
||||
onClick={() => router.push(result.link)}
|
||||
>
|
||||
<CardContent className="flex items-start gap-3 p-3">
|
||||
<div className={`mt-0.5 rounded p-1.5 ${typeColors[result.type] || 'bg-gray-500/10'}`}>
|
||||
{typeIcons[result.type]}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{result.title}</span>
|
||||
<Badge variant="outline" className="shrink-0 text-[10px] capitalize">
|
||||
{result.type}
|
||||
</Badge>
|
||||
</div>
|
||||
{result.snippet && (
|
||||
<p
|
||||
className="mt-1 text-xs text-muted-foreground line-clamp-2"
|
||||
dangerouslySetInnerHTML={{ __html: result.snippet }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ExternalLink className="mt-1 h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, requireWorkspaceAccess, createErrorResponse } from '@/lib/auth';
|
||||
import { db, tasks, habits, habitCompletions, projects, sections, domains } from '@project-e/db';
|
||||
import { and, asc, between, eq, gte, isNull, lte, sql } from 'drizzle-orm';
|
||||
|
||||
type RouteContext = { params: Promise<{ domainId: string }> };
|
||||
|
||||
interface CalendarEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
start: string;
|
||||
end: string;
|
||||
type: 'task' | 'habit' | 'project' | 'milestone';
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
color: string;
|
||||
domainId: string;
|
||||
href: string;
|
||||
priority?: string;
|
||||
difficulty?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
// GET /api/domains/[domainId]/calendar/events?from=&to=
|
||||
// Returns all events (tasks with due_date, habits scheduled for date range, project target dates)
|
||||
// joined with domain for color/title
|
||||
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||
const { domainId } = await context!.params;
|
||||
await requireWorkspaceAccess(domainId);
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const from = searchParams.get('from');
|
||||
const to = searchParams.get('to');
|
||||
const types = searchParams.get('types')?.split(',').filter(Boolean) || ['task', 'habit', 'project', 'milestone'];
|
||||
|
||||
if (!from || !to) {
|
||||
return createErrorResponse('VALIDATION_ERROR', 'from and to query params are required (ISO dates)', 400);
|
||||
}
|
||||
|
||||
const fromDate = new Date(from);
|
||||
const toDate = new Date(to);
|
||||
|
||||
// Get domain for color
|
||||
const [domain] = await db.select({ color: domains.color, name: domains.name })
|
||||
.from(domains)
|
||||
.where(eq(domains.id, domainId))
|
||||
.limit(1);
|
||||
|
||||
const domainColor = domain?.color || '#3b82f6';
|
||||
const events: CalendarEvent[] = [];
|
||||
|
||||
// 1. Tasks with due_date in range
|
||||
if (types.includes('task')) {
|
||||
const taskRows = await db.select()
|
||||
.from(tasks)
|
||||
.where(and(
|
||||
eq(tasks.domainId, domainId),
|
||||
isNull(tasks.deletedAt),
|
||||
gte(tasks.dueDate, fromDate),
|
||||
lte(tasks.dueDate, toDate),
|
||||
))
|
||||
.orderBy(asc(tasks.dueDate));
|
||||
|
||||
for (const task of taskRows) {
|
||||
if (!task.dueDate) continue;
|
||||
const color = task.priority === 'urgent' ? '#ef4444'
|
||||
: task.priority === 'high' ? '#f97316'
|
||||
: task.priority === 'medium' ? '#3b82f6'
|
||||
: '#6b7280';
|
||||
events.push({
|
||||
id: `task-${task.id}`,
|
||||
title: task.title,
|
||||
start: task.dueDate.toISOString(),
|
||||
end: task.dueDate.toISOString(),
|
||||
type: 'task',
|
||||
entityType: 'task',
|
||||
entityId: task.id,
|
||||
color,
|
||||
domainId,
|
||||
href: `/tasks/${task.id}`,
|
||||
priority: task.priority,
|
||||
status: task.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Habits — check if they have completions in range (scheduled habits)
|
||||
if (types.includes('habit')) {
|
||||
const habitRows = await db.select()
|
||||
.from(habits)
|
||||
.where(and(
|
||||
eq(habits.domainId, domainId),
|
||||
eq(habits.active, true),
|
||||
isNull(habits.deletedAt),
|
||||
));
|
||||
|
||||
for (const habit of habitRows) {
|
||||
const color = habit.difficulty === 'hard' ? '#ef4444'
|
||||
: habit.difficulty === 'medium' ? '#f97316'
|
||||
: '#22c55e';
|
||||
|
||||
// Check if habit has completions in range
|
||||
const completions = await db.select({ date: habitCompletions.date })
|
||||
.from(habitCompletions)
|
||||
.where(and(
|
||||
eq(habitCompletions.habitId, habit.id),
|
||||
gte(habitCompletions.date, fromDate),
|
||||
lte(habitCompletions.date, toDate),
|
||||
));
|
||||
|
||||
const completedDates = new Set(completions.map(c => c.date.toISOString().split('T')[0]));
|
||||
|
||||
// Generate events for each day in range (for daily habits)
|
||||
// For weekly/custom, just show the habit as a recurring event
|
||||
const current = new Date(fromDate);
|
||||
while (current <= toDate) {
|
||||
const dayOfWeek = current.getDay();
|
||||
const skipDays = (habit.skipDays || []) as number[];
|
||||
const dateStr = current.toISOString().split('T')[0];
|
||||
|
||||
if (!skipDays.includes(dayOfWeek)) {
|
||||
const isCompleted = completedDates.has(dateStr);
|
||||
events.push({
|
||||
id: `habit-${habit.id}-${dateStr}`,
|
||||
title: `${isCompleted ? '✅ ' : '○ '}${habit.name}`,
|
||||
start: current.toISOString(),
|
||||
end: current.toISOString(),
|
||||
type: 'habit',
|
||||
entityType: 'habit',
|
||||
entityId: habit.id,
|
||||
color,
|
||||
domainId,
|
||||
href: '/habits',
|
||||
difficulty: habit.difficulty,
|
||||
status: isCompleted ? 'completed' : 'pending',
|
||||
});
|
||||
}
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Projects with target_date in range
|
||||
if (types.includes('project')) {
|
||||
const projectRows = await db.select()
|
||||
.from(projects)
|
||||
.where(and(
|
||||
eq(projects.domainId, domainId),
|
||||
isNull(projects.deletedAt),
|
||||
gte(projects.targetDate, fromDate),
|
||||
lte(projects.targetDate, toDate),
|
||||
))
|
||||
.orderBy(asc(projects.targetDate));
|
||||
|
||||
for (const project of projectRows) {
|
||||
if (!project.targetDate) continue;
|
||||
events.push({
|
||||
id: `project-${project.id}`,
|
||||
title: `📁 ${project.name}`,
|
||||
start: project.targetDate.toISOString(),
|
||||
end: project.targetDate.toISOString(),
|
||||
type: 'project',
|
||||
entityType: 'project',
|
||||
entityId: project.id,
|
||||
color: project.color || '#8b5cf6',
|
||||
domainId,
|
||||
href: `/projects/${project.id}`,
|
||||
status: project.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Sections (milestones) with target_date in range
|
||||
if (types.includes('milestone')) {
|
||||
const milestoneRows = await db.select({
|
||||
id: sections.id,
|
||||
name: sections.name,
|
||||
targetDate: sections.targetDate,
|
||||
projectId: sections.projectId,
|
||||
status: sections.status,
|
||||
kind: sections.kind,
|
||||
})
|
||||
.from(sections)
|
||||
.innerJoin(projects, eq(sections.projectId, projects.id))
|
||||
.where(and(
|
||||
eq(projects.domainId, domainId),
|
||||
eq(sections.kind, 'milestone'),
|
||||
gte(sections.targetDate, fromDate),
|
||||
lte(sections.targetDate, toDate),
|
||||
))
|
||||
.orderBy(asc(sections.targetDate));
|
||||
|
||||
for (const milestone of milestoneRows) {
|
||||
if (!milestone.targetDate) continue;
|
||||
events.push({
|
||||
id: `milestone-${milestone.id}`,
|
||||
title: `🏁 ${milestone.name}`,
|
||||
start: milestone.targetDate.toISOString(),
|
||||
end: milestone.targetDate.toISOString(),
|
||||
type: 'milestone',
|
||||
entityType: 'section',
|
||||
entityId: milestone.id,
|
||||
color: '#f59e0b',
|
||||
domainId,
|
||||
href: `/projects/${milestone.projectId}`,
|
||||
status: milestone.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ events });
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
|
||||
import { recordActivity } from '@/lib/activity';
|
||||
import { db, domains } from '@project-e/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
type RouteContext = { params: Promise<{ domainId: string }> };
|
||||
|
||||
const layoutItemSchema = z.object({
|
||||
widgetId: z.string(),
|
||||
order: z.number().int(),
|
||||
enabled: z.boolean(),
|
||||
config: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
const updateLayoutSchema = z.object({
|
||||
layout: z.array(layoutItemSchema),
|
||||
});
|
||||
|
||||
// PUT /api/domains/[domainId]/dashboard/layout — Update dashboard layout
|
||||
export const PUT = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||
const { domainId } = await context!.params;
|
||||
await requireWorkspaceAccess(domainId);
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = updateLayoutSchema.parse(body);
|
||||
|
||||
const [domain] = await db.select({ id: domains.id, customFields: domains.customFields })
|
||||
.from(domains)
|
||||
.where(eq(domains.id, domainId))
|
||||
.limit(1);
|
||||
|
||||
if (!domain) {
|
||||
return createErrorResponse('NOT_FOUND', 'Domain not found', 404);
|
||||
}
|
||||
|
||||
// Store layout in domain's custom_fields
|
||||
const existingFields = (domain.customFields as Record<string, unknown>) || {};
|
||||
await db.update(domains)
|
||||
.set({
|
||||
customFields: { ...existingFields, dashboard_layout: data.layout },
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(domains.id, domainId));
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: 'updated',
|
||||
entityType: 'domain',
|
||||
entityId: domainId,
|
||||
changes: { dashboardLayout: data.layout },
|
||||
workspaceId: domainId,
|
||||
});
|
||||
|
||||
return NextResponse.json({ layout: data.layout });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
|
||||
}
|
||||
if (error instanceof ApiError) {
|
||||
return createErrorResponse(error.code, error.message, error.status);
|
||||
}
|
||||
console.error('[dashboard/layout PUT] error:', error);
|
||||
return createErrorResponse('INTERNAL_ERROR', 'Failed to update dashboard layout', 500);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
|
||||
import { recordActivity } from '@/lib/activity';
|
||||
import { db, domains } from '@project-e/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
type RouteContext = { params: Promise<{ domainId: string }> };
|
||||
|
||||
const layoutItemSchema = z.object({
|
||||
widgetId: z.string(),
|
||||
order: z.number().int(),
|
||||
enabled: z.boolean(),
|
||||
config: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
const updateLayoutSchema = z.object({
|
||||
layout: z.array(layoutItemSchema),
|
||||
});
|
||||
|
||||
// GET /api/domains/[domainId]/dashboard — Returns layout (widget order) + widget data
|
||||
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||
const { domainId } = await context!.params;
|
||||
await requireWorkspaceAccess(domainId);
|
||||
|
||||
// Verify domain exists
|
||||
const [domain] = await db.select({ id: domains.id, name: domains.name, color: domains.color, customFields: domains.customFields })
|
||||
.from(domains)
|
||||
.where(eq(domains.id, domainId))
|
||||
.limit(1);
|
||||
|
||||
if (!domain) {
|
||||
return createErrorResponse('NOT_FOUND', 'Domain not found', 404);
|
||||
}
|
||||
|
||||
// Dashboard layout is stored in the domain's custom_fields as jsonb
|
||||
// We use a convention: dashboard_layout key in custom_fields
|
||||
const storedLayout = (domain.customFields as Record<string, unknown>)?.dashboard_layout as Array<{ widgetId: string; order: number; enabled: boolean; config?: Record<string, unknown> }> | undefined;
|
||||
|
||||
const defaultLayout = [
|
||||
{ widgetId: 'today-tasks', order: 0, enabled: true },
|
||||
{ widgetId: 'habit-checklist', order: 1, enabled: true },
|
||||
{ widgetId: 'weekly-stats', order: 2, enabled: true },
|
||||
{ widgetId: 'project-progress', order: 3, enabled: true },
|
||||
{ widgetId: 'upcoming-calendar', order: 4, enabled: true },
|
||||
{ widgetId: 'recent-notes', order: 5, enabled: true },
|
||||
{ widgetId: 'activity-feed', order: 6, enabled: true },
|
||||
{ widgetId: 'quick-capture', order: 7, enabled: true },
|
||||
];
|
||||
|
||||
return NextResponse.json({ layout: storedLayout || defaultLayout });
|
||||
});
|
||||
|
||||
// PUT /api/domains/[domainId]/dashboard/layout — Update dashboard layout
|
||||
export const PUT = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||
const { domainId } = await context!.params;
|
||||
await requireWorkspaceAccess(domainId);
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = updateLayoutSchema.parse(body);
|
||||
|
||||
const [domain] = await db.select({ id: domains.id, customFields: domains.customFields })
|
||||
.from(domains)
|
||||
.where(eq(domains.id, domainId))
|
||||
.limit(1);
|
||||
|
||||
if (!domain) {
|
||||
return createErrorResponse('NOT_FOUND', 'Domain not found', 404);
|
||||
}
|
||||
|
||||
// Store layout in domain's custom_fields
|
||||
const existingFields = (domain.customFields as Record<string, unknown>) || {};
|
||||
await db.update(domains)
|
||||
.set({
|
||||
customFields: { ...existingFields, dashboard_layout: data.layout },
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(domains.id, domainId));
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: 'updated',
|
||||
entityType: 'domain',
|
||||
entityId: domainId,
|
||||
changes: { dashboardLayout: data.layout },
|
||||
workspaceId: domainId,
|
||||
});
|
||||
|
||||
return NextResponse.json({ layout: data.layout });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
|
||||
}
|
||||
if (error instanceof ApiError) {
|
||||
return createErrorResponse(error.code, error.message, error.status);
|
||||
}
|
||||
console.error('[dashboard PUT] error:', error);
|
||||
return createErrorResponse('INTERNAL_ERROR', 'Failed to update dashboard layout', 500);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, requireWorkspaceAccess } from '@/lib/auth';
|
||||
import { db, activityFeed } from '@project-e/db';
|
||||
import { and, desc, eq } from 'drizzle-orm';
|
||||
|
||||
type RouteContext = { params: Promise<{ domainId: string }> };
|
||||
|
||||
// GET /api/domains/[domainId]/dashboard/widgets/activity-feed
|
||||
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||
const { domainId } = await context!.params;
|
||||
await requireWorkspaceAccess(domainId);
|
||||
|
||||
const items = await db.select()
|
||||
.from(activityFeed)
|
||||
.where(eq(activityFeed.workspaceId, domainId))
|
||||
.orderBy(desc(activityFeed.createdAt))
|
||||
.limit(20);
|
||||
|
||||
return NextResponse.json({ items });
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, requireWorkspaceAccess } from '@/lib/auth';
|
||||
import { db, habits, habitCompletions } from '@project-e/db';
|
||||
import { and, eq, gte, isNull, lte, sql } from 'drizzle-orm';
|
||||
|
||||
type RouteContext = { params: Promise<{ domainId: string }> };
|
||||
|
||||
// GET /api/domains/[domainId]/dashboard/widgets/habit-checklist
|
||||
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||
const { domainId } = await context!.params;
|
||||
await requireWorkspaceAccess(domainId);
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
const activeHabits = await db.select()
|
||||
.from(habits)
|
||||
.where(and(
|
||||
eq(habits.domainId, domainId),
|
||||
eq(habits.active, true),
|
||||
isNull(habits.deletedAt),
|
||||
));
|
||||
|
||||
// Check which habits are completed today
|
||||
const items = [];
|
||||
for (const habit of activeHabits) {
|
||||
const [completion] = await db.select({ count: sql<number>`count(*)` })
|
||||
.from(habitCompletions)
|
||||
.where(and(
|
||||
eq(habitCompletions.habitId, habit.id),
|
||||
gte(habitCompletions.date, today),
|
||||
lte(habitCompletions.date, tomorrow),
|
||||
));
|
||||
|
||||
const completed = Number(completion?.count || 0) > 0;
|
||||
items.push({
|
||||
...habit,
|
||||
completedToday: completed,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ items });
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, requireWorkspaceAccess } from '@/lib/auth';
|
||||
import { db, projects, tasks } from '@project-e/db';
|
||||
import { and, eq, inArray, isNull, sql } from 'drizzle-orm';
|
||||
|
||||
type RouteContext = { params: Promise<{ domainId: string }> };
|
||||
|
||||
// GET /api/domains/[domainId]/dashboard/widgets/project-progress
|
||||
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||
const { domainId } = await context!.params;
|
||||
await requireWorkspaceAccess(domainId);
|
||||
|
||||
const activeProjects = await db.select()
|
||||
.from(projects)
|
||||
.where(and(
|
||||
eq(projects.domainId, domainId),
|
||||
inArray(projects.status, ['active', 'paused']),
|
||||
isNull(projects.deletedAt),
|
||||
));
|
||||
|
||||
// Compute progress for each project
|
||||
const items = [];
|
||||
for (const project of activeProjects) {
|
||||
const [totalResult] = await db.select({ count: sql<number>`count(*)` })
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.projectId, project.id), isNull(tasks.deletedAt)));
|
||||
|
||||
const [completedResult] = await db.select({ count: sql<number>`count(*)` })
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.projectId, project.id), eq(tasks.status, 'done'), isNull(tasks.deletedAt)));
|
||||
|
||||
const total = Number(totalResult?.count || 0);
|
||||
const completed = Number(completedResult?.count || 0);
|
||||
const progress = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
|
||||
items.push({
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
status: project.status,
|
||||
color: project.color,
|
||||
targetDate: project.targetDate,
|
||||
taskCount: total,
|
||||
completedCount: completed,
|
||||
progress,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ items });
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, requireWorkspaceAccess } from '@/lib/auth';
|
||||
import { db, notes } from '@project-e/db';
|
||||
import { and, desc, eq, isNull } from 'drizzle-orm';
|
||||
|
||||
type RouteContext = { params: Promise<{ domainId: string }> };
|
||||
|
||||
// GET /api/domains/[domainId]/dashboard/widgets/recent-notes
|
||||
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||
const { domainId } = await context!.params;
|
||||
await requireWorkspaceAccess(domainId);
|
||||
|
||||
const items = await db.select({
|
||||
id: notes.id,
|
||||
title: notes.title,
|
||||
updatedAt: notes.updatedAt,
|
||||
isPinned: notes.isPinned,
|
||||
})
|
||||
.from(notes)
|
||||
.where(and(
|
||||
eq(notes.domainId, domainId),
|
||||
eq(notes.isArchived, false),
|
||||
isNull(notes.deletedAt),
|
||||
))
|
||||
.orderBy(desc(notes.updatedAt))
|
||||
.limit(5);
|
||||
|
||||
return NextResponse.json({ items });
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, requireWorkspaceAccess } from '@/lib/auth';
|
||||
import { db, tasks, habits, habitCompletions, projects, notes, activityFeed, domains } from '@project-e/db';
|
||||
import { and, asc, desc, eq, gte, inArray, isNull, lte, sql } from 'drizzle-orm';
|
||||
|
||||
type RouteContext = { params: Promise<{ domainId: string }> };
|
||||
|
||||
// GET /api/domains/[domainId]/dashboard/widgets/today-tasks
|
||||
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||
const { domainId } = await context!.params;
|
||||
await requireWorkspaceAccess(domainId);
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
const items = await db.select()
|
||||
.from(tasks)
|
||||
.where(and(
|
||||
eq(tasks.domainId, domainId),
|
||||
isNull(tasks.deletedAt),
|
||||
gte(tasks.dueDate, today),
|
||||
lte(tasks.dueDate, tomorrow),
|
||||
))
|
||||
.orderBy(asc(tasks.priority))
|
||||
.limit(10);
|
||||
|
||||
return NextResponse.json({ items });
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, requireWorkspaceAccess } from '@/lib/auth';
|
||||
import { db, tasks, projects, sections } from '@project-e/db';
|
||||
import { and, asc, eq, gte, isNull, lte, sql } from 'drizzle-orm';
|
||||
|
||||
type RouteContext = { params: Promise<{ domainId: string }> };
|
||||
|
||||
// GET /api/domains/[domainId]/dashboard/widgets/upcoming-calendar
|
||||
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||
const { domainId } = await context!.params;
|
||||
await requireWorkspaceAccess(domainId);
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const nextWeek = new Date(today);
|
||||
nextWeek.setDate(nextWeek.getDate() + 7);
|
||||
|
||||
// Tasks due in next 7 days
|
||||
const upcomingTasks = await db.select({
|
||||
id: tasks.id,
|
||||
title: tasks.title,
|
||||
dueDate: tasks.dueDate,
|
||||
priority: tasks.priority,
|
||||
status: tasks.status,
|
||||
})
|
||||
.from(tasks)
|
||||
.where(and(
|
||||
eq(tasks.domainId, domainId),
|
||||
isNull(tasks.deletedAt),
|
||||
gte(tasks.dueDate, today),
|
||||
lte(tasks.dueDate, nextWeek),
|
||||
))
|
||||
.orderBy(asc(tasks.dueDate))
|
||||
.limit(10);
|
||||
|
||||
// Projects with target dates in next 7 days
|
||||
const upcomingProjects = await db.select({
|
||||
id: projects.id,
|
||||
name: projects.name,
|
||||
targetDate: projects.targetDate,
|
||||
status: projects.status,
|
||||
color: projects.color,
|
||||
})
|
||||
.from(projects)
|
||||
.where(and(
|
||||
eq(projects.domainId, domainId),
|
||||
isNull(projects.deletedAt),
|
||||
gte(projects.targetDate, today),
|
||||
lte(projects.targetDate, nextWeek),
|
||||
))
|
||||
.orderBy(asc(projects.targetDate))
|
||||
.limit(5);
|
||||
|
||||
return NextResponse.json({
|
||||
tasks: upcomingTasks,
|
||||
projects: upcomingProjects,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, requireWorkspaceAccess } from '@/lib/auth';
|
||||
import { db, tasks, habits, habitCompletions } from '@project-e/db';
|
||||
import { and, eq, gte, isNull, lte, sql } from 'drizzle-orm';
|
||||
|
||||
type RouteContext = { params: Promise<{ domainId: string }> };
|
||||
|
||||
// GET /api/domains/[domainId]/dashboard/widgets/weekly-stats
|
||||
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||
const { domainId } = await context!.params;
|
||||
await requireWorkspaceAccess(domainId);
|
||||
|
||||
const now = new Date();
|
||||
const weekStart = new Date(now);
|
||||
weekStart.setDate(weekStart.getDate() - weekStart.getDay());
|
||||
weekStart.setHours(0, 0, 0, 0);
|
||||
const weekEnd = new Date(weekStart);
|
||||
weekEnd.setDate(weekEnd.getDate() + 7);
|
||||
|
||||
// Task completions this week
|
||||
const [taskCompletions] = await db.select({ count: sql<number>`count(*)` })
|
||||
.from(tasks)
|
||||
.where(and(
|
||||
eq(tasks.domainId, domainId),
|
||||
eq(tasks.status, 'done'),
|
||||
gte(tasks.completedAt, weekStart),
|
||||
lte(tasks.completedAt, weekEnd),
|
||||
isNull(tasks.deletedAt),
|
||||
));
|
||||
|
||||
// Habit completions this week
|
||||
const [habitCompletionsCount] = await db.select({ count: sql<number>`count(*)` })
|
||||
.from(habitCompletions)
|
||||
.innerJoin(habits, eq(habitCompletions.habitId, habits.id))
|
||||
.where(and(
|
||||
eq(habits.domainId, domainId),
|
||||
gte(habitCompletions.date, weekStart),
|
||||
lte(habitCompletions.date, weekEnd),
|
||||
isNull(habits.deletedAt),
|
||||
));
|
||||
|
||||
// Streak counts
|
||||
const activeHabits = await db.select({ id: habits.id, streakCount: habits.streakCount, bestStreak: habits.bestStreak })
|
||||
.from(habits)
|
||||
.where(and(
|
||||
eq(habits.domainId, domainId),
|
||||
eq(habits.active, true),
|
||||
isNull(habits.deletedAt),
|
||||
));
|
||||
|
||||
const totalStreak = activeHabits.reduce((sum, h) => sum + (h.streakCount || 0), 0);
|
||||
const bestStreak = Math.max(...activeHabits.map(h => h.bestStreak || 0), 0);
|
||||
|
||||
return NextResponse.json({
|
||||
taskCompletions: Number(taskCompletions?.count || 0),
|
||||
habitCompletions: Number(habitCompletionsCount?.count || 0),
|
||||
totalStreak,
|
||||
bestStreak,
|
||||
activeHabits: activeHabits.length,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
|
||||
import { recordActivity } from '@/lib/activity';
|
||||
import { db, tasks } from '@project-e/db';
|
||||
import { and, eq, isNull } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
const scheduleSchema = z.object({
|
||||
dueDate: z.string().datetime().nullable(),
|
||||
});
|
||||
|
||||
type RouteContext = { params: Promise<{ domainId: string; id: string }> };
|
||||
|
||||
// PATCH /api/domains/[domainId]/tasks/[id]/schedule — Reschedule a task via drag
|
||||
export const PATCH = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||
const { domainId, id } = await context!.params;
|
||||
await requireWorkspaceAccess(domainId);
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = scheduleSchema.parse(body);
|
||||
|
||||
// Verify task exists
|
||||
const [existing] = await db.select()
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.id, id), eq(tasks.domainId, domainId), isNull(tasks.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return createErrorResponse('NOT_FOUND', 'Task not found', 404);
|
||||
}
|
||||
|
||||
const [updated] = await db.update(tasks)
|
||||
.set({
|
||||
dueDate: data.dueDate ? new Date(data.dueDate) : null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(tasks.id, id))
|
||||
.returning();
|
||||
|
||||
// Record activity
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: 'updated',
|
||||
entityType: 'task',
|
||||
entityId: id,
|
||||
changes: { dueDate: data.dueDate, previousDueDate: existing.dueDate?.toISOString() || null },
|
||||
workspaceId: domainId,
|
||||
});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
|
||||
}
|
||||
if (error instanceof ApiError) {
|
||||
return createErrorResponse(error.code, error.message, error.status);
|
||||
}
|
||||
console.error('[schedule PATCH] error:', error);
|
||||
return createErrorResponse('INTERNAL_ERROR', 'Failed to reschedule task', 500);
|
||||
}
|
||||
});
|
||||
@@ -4,73 +4,39 @@
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { searchEntities } from '@/lib/search-service';
|
||||
|
||||
// GET /api/search — Cross-entity full-text search
|
||||
//
|
||||
// Implementation note: the underlying data layer (`lib/database.ts`) uses a
|
||||
// JavaScript filter parser that only supports `=, !=, <=, >=, <, >` — it does
|
||||
// NOT understand PocketBase's `~` (contains) or `||` (or) operators. To make
|
||||
// search actually return results we fetch each collection's full list and
|
||||
// filter in-process with a case-insensitive substring match on the searchable
|
||||
// fields. This is fine at the current data scale and avoids the silent
|
||||
// zero-result bug.
|
||||
export const GET = withAuth(async (request: NextRequest, _user) => {
|
||||
// GET /api/search?q=&type=&domain=&limit=&offset=
|
||||
// Full-text search across all entity types using PostgreSQL tsvector/tsquery
|
||||
export const GET = withAuth(async (request: NextRequest, user) => {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = (searchParams.get('q') || '').trim();
|
||||
const types = (
|
||||
searchParams.get('types')?.split(',') || ['tasks', 'habits', 'projects', 'notes', 'reports']
|
||||
).filter((t) =>
|
||||
['tasks', 'habits', 'projects', 'notes', 'reports'].includes(t)
|
||||
);
|
||||
const limit = Math.max(1, Math.min(50, parseInt(searchParams.get('limit') || '10')));
|
||||
const q = (searchParams.get('q') || '').trim();
|
||||
const types = searchParams.get('types')?.split(',').filter(Boolean) || ['task', 'note', 'project', 'habit', 'domain'];
|
||||
const domain = searchParams.get('domain') || undefined;
|
||||
const limit = Math.max(1, Math.min(50, parseInt(searchParams.get('limit') || '20')));
|
||||
const offset = Math.max(0, parseInt(searchParams.get('offset') || '0'));
|
||||
|
||||
if (!query) {
|
||||
return NextResponse.json({ results: [] });
|
||||
if (!q) {
|
||||
return NextResponse.json({ results: [], totalCount: 0 });
|
||||
}
|
||||
|
||||
const needle = query.toLowerCase();
|
||||
const pb = createPocketBaseClient();
|
||||
const results: Array<{ type: string; items: unknown[] }> = [];
|
||||
try {
|
||||
const { results, totalCount } = await searchEntities({
|
||||
query: q,
|
||||
types,
|
||||
domainId: domain,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
type Searchable = Record<string, unknown> & { id: string };
|
||||
const matches = (record: Searchable, fields: string[]): boolean => {
|
||||
for (const f of fields) {
|
||||
const value = record[f];
|
||||
if (typeof value === 'string' && value.toLowerCase().includes(needle)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const searchableFields: Record<string, string[]> = {
|
||||
tasks: ['title', 'description'],
|
||||
habits: ['name', 'description'],
|
||||
projects: ['name', 'description'],
|
||||
notes: ['title', 'content'],
|
||||
reports: ['title', 'content'],
|
||||
};
|
||||
|
||||
for (const type of types) {
|
||||
try {
|
||||
const items = (await pb.collection(type).getFullList()) as Searchable[];
|
||||
const filtered = items
|
||||
.filter((record) => matches(record, searchableFields[type] || []))
|
||||
.slice(0, limit)
|
||||
.map((record) => ({ id: record.id, title: getTitle(record, type) }));
|
||||
results.push({ type, items: filtered });
|
||||
} catch {
|
||||
// Skip collections that fail (e.g. missing or inaccessible)
|
||||
}
|
||||
return NextResponse.json({
|
||||
results,
|
||||
totalCount,
|
||||
query: q,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[search GET] error:', error);
|
||||
return createErrorResponse('INTERNAL_ERROR', 'Search failed', 500);
|
||||
}
|
||||
|
||||
return NextResponse.json({ results });
|
||||
});
|
||||
|
||||
function getTitle(record: Record<string, unknown>, type: string): string {
|
||||
const title = record.title ?? record.name;
|
||||
if (typeof title === 'string' && title.length > 0) return title;
|
||||
return `Untitled ${type.slice(0, -1)}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user