feat: implement Phase 2 Core features for AutoScheduler GTD System

- ConnectWise Manage Integration:
  - ConnectWiseModule with service, controller, entity
  - API endpoints for connection CRUD and sync
  - Syncs service tickets, project tickets, zero-ticket projects
  - Stores ConnectWise priority/SLA in task metadata

- Intelligent Calendar Scheduling:
  - CalendarModule with connection and event entities
  - Support for CalDAV, Microsoft Graph, Google Calendar providers
  - CalendarService with sync methods for all providers
  - SchedulingModule with automatic scheduling engine
  - Finds available slots respecting working hours
  - Groups tasks by context, respects priority and due dates

- Interactive Calendar Week View:
  - FullCalendar with timeGridWeek view
  - Drag-and-drop task rescheduling
  - Tasks auto-lock when manually moved
  - Color-coded by context
  - Regenerate Schedule button

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-01-11 09:58:15 +00:00
parent e7ffcce768
commit 9c6b85f28a
29 changed files with 1863 additions and 47 deletions

View File

@@ -13,6 +13,11 @@
"clean": "rm -rf dist"
},
"dependencies": {
"@fullcalendar/core": "^6.1.20",
"@fullcalendar/daygrid": "^6.1.20",
"@fullcalendar/interaction": "^6.1.20",
"@fullcalendar/react": "^6.1.20",
"@fullcalendar/timegrid": "^6.1.20",
"@nick-tracker/shared-types": "*",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",

View File

@@ -7,7 +7,7 @@ import { InboxPage } from './pages/InboxPage';
import { CalendarPage } from './pages/CalendarPage';
import { ProjectsPage } from './pages/ProjectsPage';
import { SettingsPage } from './pages/SettingsPage';
import { Toaster } from './components/ui/Toaster';
import { ToastProvider } from './components/ui/Toaster';
function PrivateRoute({ children }: { children: React.ReactNode }) {
const token = useAuthStore((state) => state.token);
@@ -19,7 +19,7 @@ function PrivateRoute({ children }: { children: React.ReactNode }) {
function App() {
return (
<>
<ToastProvider>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
@@ -38,8 +38,7 @@ function App() {
<Route path="settings" element={<SettingsPage />} />
</Route>
</Routes>
<Toaster />
</>
</ToastProvider>
);
}

View File

@@ -3,7 +3,63 @@ import * as ToastPrimitive from '@radix-ui/react-toast';
import { X } from 'lucide-react';
import { cn } from '../../lib/utils';
const ToastProvider = ToastPrimitive.Provider;
interface ToastData {
id: string;
title: string;
description?: string;
variant?: 'default' | 'destructive';
}
interface ToastContextType {
toasts: ToastData[];
addToast: (toast: Omit<ToastData, 'id'>) => void;
removeToast: (id: string) => void;
}
const ToastContext = React.createContext<ToastContextType | undefined>(undefined);
export function useToast() {
const context = React.useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
}
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = React.useState<ToastData[]>([]);
const addToast = React.useCallback((toast: Omit<ToastData, 'id'>) => {
const id = Math.random().toString(36).substring(7);
setToasts((prev) => [...prev, { ...toast, id }]);
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 5000);
}, []);
const removeToast = React.useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
return (
<ToastContext.Provider value={{ toasts, addToast, removeToast }}>
<ToastPrimitive.Provider>
{children}
{toasts.map((toast) => (
<Toast key={toast.id} variant={toast.variant}>
<div className="grid gap-1">
<ToastTitle>{toast.title}</ToastTitle>
{toast.description && <ToastDescription>{toast.description}</ToastDescription>}
</div>
<ToastClose onClick={() => removeToast(toast.id)} />
</Toast>
))}
<ToastViewport />
</ToastPrimitive.Provider>
</ToastContext.Provider>
);
}
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Viewport>
@@ -81,9 +137,9 @@ ToastDescription.displayName = ToastPrimitive.Description.displayName;
export function Toaster() {
return (
<ToastProvider>
<ToastPrimitive.Provider>
<ToastViewport />
</ToastProvider>
</ToastPrimitive.Provider>
);
}

View File

@@ -97,3 +97,42 @@ export const userApi = {
getPreferences: () => api.get('/users/preferences'),
updatePreferences: (data: Record<string, unknown>) => api.patch('/users/preferences', data),
};
// Calendar API
export const calendarApi = {
getConnections: () => api.get('/connections/calendar'),
createConnection: (data: {
provider: string;
calendarUrl?: string;
credentials?: {
username?: string;
password?: string;
accessToken?: string;
refreshToken?: string;
};
}) => api.post('/connections/calendar', data),
deleteConnection: (id: string) => api.delete(`/connections/calendar/${id}`),
syncConnection: (id: string) => api.post(`/connections/calendar/${id}/sync`),
getEvents: (start: string, end: string) =>
api.get('/calendar/events', { params: { start, end } }),
};
// Schedule API
export const scheduleApi = {
regenerate: () => api.post('/schedule/regenerate'),
clear: () => api.delete('/schedule/clear'),
};
// ConnectWise API
export const connectWiseApi = {
getConnections: () => api.get('/connections/connectwise'),
createConnection: (data: {
companyId: string;
publicKey: string;
privateKey: string;
apiUrl: string;
memberId: string;
}) => api.post('/connections/connectwise', data),
deleteConnection: (id: string) => api.delete(`/connections/connectwise/${id}`),
syncConnection: (id: string) => api.post(`/connections/connectwise/${id}/sync`),
};

View File

@@ -1,16 +1,270 @@
import { useState, useCallback, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import FullCalendar from '@fullcalendar/react';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';
import type { EventDropArg, EventInput } from '@fullcalendar/core';
import { startOfWeek, endOfWeek, addDays } from 'date-fns';
import { tasksApi, calendarApi, scheduleApi } from '../lib/api';
import { Button } from '../components/ui/Button';
import { useToast } from '../components/ui/Toaster';
import type { TaskResponseDto, CalendarEventResponseDto } from '@nick-tracker/shared-types';
interface EventResizeArg {
event: {
extendedProps: { taskId?: string; [key: string]: unknown };
start: Date | null;
end: Date | null;
};
revert: () => void;
}
const CONTEXT_COLORS: Record<string, string> = {
DESK: '#3b82f6',
PHONE: '#22c55e',
ERRAND: '#f59e0b',
HOMELAB: '#8b5cf6',
ANYWHERE: '#64748b',
};
export function CalendarPage() {
const queryClient = useQueryClient();
const { addToast } = useToast();
const [currentDate, setCurrentDate] = useState(new Date());
const weekStart = useMemo(() => startOfWeek(currentDate, { weekStartsOn: 1 }), [currentDate]);
const weekEnd = useMemo(() => endOfWeek(currentDate, { weekStartsOn: 1 }), [currentDate]);
const { data: tasks = [] } = useQuery<TaskResponseDto[]>({
queryKey: ['tasks', 'scheduled'],
queryFn: async () => {
const response = await tasksApi.getAll({ status: 'NEXT_ACTION' });
return response.data.filter((t: TaskResponseDto) => t.scheduledStart);
},
});
const { data: calendarEvents = [] } = useQuery<CalendarEventResponseDto[]>({
queryKey: ['calendar-events', weekStart.toISOString(), weekEnd.toISOString()],
queryFn: async () => {
const response = await calendarApi.getEvents(
weekStart.toISOString(),
addDays(weekEnd, 1).toISOString(),
);
return response.data;
},
});
const updateTaskMutation = useMutation({
mutationFn: async ({
id,
scheduledStart,
scheduledEnd,
}: {
id: string;
scheduledStart: string;
scheduledEnd: string;
}) => {
await tasksApi.update(id, { scheduledStart, scheduledEnd, isLocked: true });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
addToast({
title: 'Task rescheduled',
description: 'Task has been moved and locked',
});
},
onError: () => {
addToast({
title: 'Error',
description: 'Failed to reschedule task',
variant: 'destructive',
});
},
});
const regenerateMutation = useMutation({
mutationFn: () => scheduleApi.regenerate(),
onSuccess: (response) => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
addToast({
title: 'Schedule regenerated',
description: `${response.data.scheduledCount} tasks scheduled`,
});
},
onError: () => {
addToast({
title: 'Error',
description: 'Failed to regenerate schedule',
variant: 'destructive',
});
},
});
const handleEventDrop = useCallback(
(info: EventDropArg) => {
const taskId = info.event.extendedProps.taskId;
if (!taskId) {
info.revert();
return;
}
const start = info.event.start;
const end = info.event.end;
if (start && end) {
updateTaskMutation.mutate({
id: taskId,
scheduledStart: start.toISOString(),
scheduledEnd: end.toISOString(),
});
}
},
[updateTaskMutation],
);
const handleEventResize = useCallback(
(info: EventResizeArg) => {
const taskId = info.event.extendedProps.taskId;
if (!taskId) {
info.revert();
return;
}
const start = info.event.start;
const end = info.event.end;
if (start && end) {
updateTaskMutation.mutate({
id: taskId,
scheduledStart: start.toISOString(),
scheduledEnd: end.toISOString(),
});
}
},
[updateTaskMutation],
);
const events: EventInput[] = useMemo(() => {
const taskEvents: EventInput[] = tasks.map((task) => ({
id: `task-${task.id}`,
title: `${task.isLocked ? '🔒 ' : ''}${task.title}`,
start: task.scheduledStart,
end: task.scheduledEnd,
backgroundColor: CONTEXT_COLORS[task.context || 'ANYWHERE'],
borderColor: CONTEXT_COLORS[task.context || 'ANYWHERE'],
editable: true,
extendedProps: {
taskId: task.id,
context: task.context,
isLocked: task.isLocked,
type: 'task',
},
}));
const calEvents: EventInput[] = calendarEvents.map((event) => ({
id: `cal-${event.id}`,
title: event.title,
start: event.startTime,
end: event.endTime,
backgroundColor: '#e2e8f0',
borderColor: '#94a3b8',
textColor: '#475569',
editable: false,
extendedProps: {
type: 'calendar',
source: event.source,
},
}));
return [...taskEvents, ...calEvents];
}, [tasks, calendarEvents]);
return (
<div className="p-8">
<div className="max-w-5xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold mb-2">Calendar</h1>
<p className="text-muted-foreground">
View and manage your scheduled tasks.
</p>
<div className="max-w-6xl mx-auto">
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-3xl font-bold mb-2">Calendar</h1>
<p className="text-muted-foreground">
View and manage your scheduled tasks. Drag to reschedule.
</p>
</div>
<Button
onClick={() => regenerateMutation.mutate()}
disabled={regenerateMutation.isPending}
>
{regenerateMutation.isPending ? 'Scheduling...' : 'Regenerate Schedule'}
</Button>
</div>
<div className="bg-muted rounded-lg p-12 text-center text-muted-foreground">
Calendar view will be implemented in Phase 2 with FullCalendar integration.
<div className="flex gap-4 mb-4">
<div className="flex items-center gap-2">
<div
className="w-4 h-4 rounded"
style={{ backgroundColor: CONTEXT_COLORS.DESK }}
/>
<span className="text-sm">@desk</span>
</div>
<div className="flex items-center gap-2">
<div
className="w-4 h-4 rounded"
style={{ backgroundColor: CONTEXT_COLORS.PHONE }}
/>
<span className="text-sm">@phone</span>
</div>
<div className="flex items-center gap-2">
<div
className="w-4 h-4 rounded"
style={{ backgroundColor: CONTEXT_COLORS.ERRAND }}
/>
<span className="text-sm">@errand</span>
</div>
<div className="flex items-center gap-2">
<div
className="w-4 h-4 rounded"
style={{ backgroundColor: CONTEXT_COLORS.HOMELAB }}
/>
<span className="text-sm">@homelab</span>
</div>
<div className="flex items-center gap-2">
<div
className="w-4 h-4 rounded"
style={{ backgroundColor: CONTEXT_COLORS.ANYWHERE }}
/>
<span className="text-sm">@anywhere</span>
</div>
<div className="flex items-center gap-2">
<div
className="w-4 h-4 rounded"
style={{ backgroundColor: '#e2e8f0' }}
/>
<span className="text-sm">Calendar events</span>
</div>
</div>
<div className="bg-card rounded-lg border p-4">
<FullCalendar
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
initialView="timeGridWeek"
headerToolbar={{
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay',
}}
events={events}
editable={true}
droppable={true}
eventDrop={handleEventDrop}
eventResize={handleEventResize}
slotMinTime="06:00:00"
slotMaxTime="22:00:00"
allDaySlot={false}
weekends={true}
nowIndicator={true}
height="auto"
datesSet={(dateInfo) => setCurrentDate(dateInfo.start)}
/>
</div>
</div>
</div>