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,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>
|
||||
|
||||
Reference in New Issue
Block a user