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:
2026-07-29 07:32:47 -04:00
parent 40a26d2672
commit eba1d78fb9
39 changed files with 11525 additions and 768 deletions
+258 -147
View File
@@ -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>
+106 -142
View File
@@ -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>
+291
View File
@@ -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 &ldquo;{query}&rdquo;
</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>
);
}