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

@@ -1,28 +1,30 @@
---
active: true
iteration: 1
max_iterations: 30
completion_promise: "PHASE_1_COMPLETE"
started_at: "2026-01-11T08:23:00Z"
max_iterations: 40
completion_promise: "PHASE_2_COMPLETE"
started_at: "2026-01-11T09:43:37Z"
---
# Phase 1: Foundation
# Phase 2: Core
## Context
Read PROMPT.md for full project requirements and context.
This prompt focuses ONLY on Phase 1: Foundation.
This prompt focuses ONLY on Phase 2: Core.
## Phase Objective
Project setup, core infrastructure, and initial configuration
Main functionality and core features implementation
## Phase 1 Tasks
## Phase 2 Tasks
- [ ] GTD Inbox Capture: Multi-source task capture system that ingests tasks from manual web form, REST API, email (IMAP/Microsoft Graph), and ConnectWise Manage sync into an unprocessed inbox for later GTD clarification
- Acceptance: Manual tasks can be submitted via web form quick-add and appear in inbox
- [ ] GTD Processing Workflow: Interactive inbox processing interface that guides users through GTD clarification: converting raw inbox items into Next Actions with context tags, Projects, Waiting For items, Someday/Maybe, Reference Material, Tickler items, or Trash
- Acceptance: Inbox view displays unprocessed items with processing workflow controls
- [ ] ConnectWise Manage Integration: Read-only sync from ConnectWise Manage that imports service tickets, project tickets, and projects assigned to user. Projects with zero tickets surface as planning tasks. ConnectWise priority/SLA displayed for reference only; user assigns manual priority
- Acceptance: ConnectWise API integration syncs assigned service tickets as inbox items
- [ ] Intelligent Calendar Scheduling: Automatic scheduling engine that pulls from CalDAV calendars (Nextcloud, Google Calendar, Outlook via Microsoft Graph) and places actionable tasks into available time slots, respecting working hours, context constraints, deadlines, and manual priority. Supports drag-drop manual override and task locking
- Acceptance: Engine reads existing events from CalDAV/Google/Outlook calendars
- [ ] Interactive Calendar Week View: React SPA with interactive week-view calendar displaying scheduled tasks and calendar events. Supports drag-and-drop task rescheduling, manual time adjustments, and real-time updates when scheduling changes occur
- Acceptance: Week view renders all scheduled tasks and synced calendar events
## Working Instructions
@@ -46,7 +48,7 @@ Project setup, core infrastructure, and initial configuration
## Verification
After completing all Phase 1 tasks:
After completing all Phase 2 tasks:
```bash
npm run build && npm run test && npm run lint
```
@@ -55,12 +57,12 @@ All commands must pass with zero errors.
## Completion
When ALL Phase 1 tasks are complete and verified:
When ALL Phase 2 tasks are complete and verified:
- All features for this phase pass their acceptance criteria
- prd.json shows passes: true for all Phase 1 features
- prd.json shows passes: true for all Phase 2 features
- Build, test, and lint all pass
Output: <promise>PHASE_1_COMPLETE</promise>
Output: <promise>PHASE_2_COMPLETE</promise>
If blocked and cannot proceed:
Output: <promise>ABORT_BLOCKED</promise>

94
package-lock.json generated
View File

@@ -677,7 +677,6 @@
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"dev": true,
"engines": {
"node": ">=6.9.0"
}
@@ -1437,6 +1436,51 @@
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="
},
"node_modules/@fullcalendar/core": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz",
"integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==",
"dependencies": {
"preact": "~10.12.1"
}
},
"node_modules/@fullcalendar/daygrid": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.20.tgz",
"integrity": "sha512-AO9vqhkLP77EesmJzuU+IGXgxNulsA8mgQHynclJ8U70vSwAVnbcLG9qftiTAFSlZjiY/NvhE7sflve6cJelyQ==",
"peerDependencies": {
"@fullcalendar/core": "~6.1.20"
}
},
"node_modules/@fullcalendar/interaction": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.20.tgz",
"integrity": "sha512-p6txmc5txL0bMiPaJxe2ip6o0T384TyoD2KGdsU6UjZ5yoBlaY+dg7kxfnYKpYMzEJLG58n+URrHr2PgNL2fyA==",
"peerDependencies": {
"@fullcalendar/core": "~6.1.20"
}
},
"node_modules/@fullcalendar/react": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/react/-/react-6.1.20.tgz",
"integrity": "sha512-1w0pZtceaUdfAnxMSCGHCQalhi+mR1jOe76sXzyAXpcPz/Lf0zHSdcGK/U2XpZlnQgQtBZW+d+QBnnzVQKCxAA==",
"peerDependencies": {
"@fullcalendar/core": "~6.1.20",
"react": "^16.7.0 || ^17 || ^18 || ^19",
"react-dom": "^16.7.0 || ^17 || ^18 || ^19"
}
},
"node_modules/@fullcalendar/timegrid": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.20.tgz",
"integrity": "sha512-4H+/MWbz3ntA50lrPif+7TsvMeX3R1GSYjiLULz0+zEJ7/Yfd9pupZmAwUs/PBpA6aAcFmeRr0laWfcz1a9V1A==",
"dependencies": {
"@fullcalendar/daygrid": "~6.1.20"
},
"peerDependencies": {
"@fullcalendar/core": "~6.1.20"
}
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
@@ -2143,6 +2187,32 @@
"node-pre-gyp": "bin/node-pre-gyp"
}
},
"node_modules/@microsoft/microsoft-graph-client": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@microsoft/microsoft-graph-client/-/microsoft-graph-client-3.0.7.tgz",
"integrity": "sha512-/AazAV/F+HK4LIywF9C+NYHcJo038zEnWkteilcxC1FM/uK/4NVGDKGrxx7nNq1ybspAroRKT4I1FHfxQzxkUw==",
"dependencies": {
"@babel/runtime": "^7.12.5",
"tslib": "^2.2.0"
},
"engines": {
"node": ">=12.0.0"
},
"peerDependenciesMeta": {
"@azure/identity": {
"optional": true
},
"@azure/msal-browser": {
"optional": true
},
"buffer": {
"optional": true
},
"stream-browserify": {
"optional": true
}
}
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
@@ -8742,6 +8812,11 @@
"node": ">=10.17.0"
}
},
"node_modules/ical.js": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/ical.js/-/ical.js-2.2.1.tgz",
"integrity": "sha512-yK/UlPbEs316igb/tjRgbFA8ZV75rCsBJp/hWOatpyaPNlgw0dGDmU+FoicOcwX4xXkeXOkYiOmCqNPFpNPkQg=="
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -12312,6 +12387,15 @@
"node": ">=0.10.0"
}
},
"node_modules/preact": {
"version": "10.12.1",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
"integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -16624,6 +16708,7 @@
"name": "@nick-tracker/backend",
"version": "1.0.0",
"dependencies": {
"@microsoft/microsoft-graph-client": "^3.0.7",
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.3.0",
@@ -16635,12 +16720,14 @@
"@nestjs/typeorm": "^10.0.1",
"@nestjs/websockets": "^10.3.0",
"@nick-tracker/shared-types": "*",
"axios": "^1.13.2",
"bcrypt": "^5.1.1",
"bull": "^4.12.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"date-fns": "^3.2.0",
"helmet": "^7.1.0",
"ical.js": "^2.2.1",
"ioredis": "^5.3.2",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
@@ -16675,6 +16762,11 @@
"name": "@nick-tracker/frontend",
"version": "1.0.0",
"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

@@ -20,6 +20,7 @@
"migration:revert": "typeorm migration:revert -d src/config/typeorm.config.ts"
},
"dependencies": {
"@microsoft/microsoft-graph-client": "^3.0.7",
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.3.0",
@@ -31,12 +32,14 @@
"@nestjs/typeorm": "^10.0.1",
"@nestjs/websockets": "^10.3.0",
"@nick-tracker/shared-types": "*",
"axios": "^1.13.2",
"bcrypt": "^5.1.1",
"bull": "^4.12.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"date-fns": "^3.2.0",
"helmet": "^7.1.0",
"ical.js": "^2.2.1",
"ioredis": "^5.3.2",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",

View File

@@ -8,10 +8,16 @@ import { InboxModule } from './modules/inbox/inbox.module';
import { TasksModule } from './modules/tasks/tasks.module';
import { ProjectsModule } from './modules/projects/projects.module';
import { HealthModule } from './modules/health/health.module';
import { ConnectWiseModule } from './modules/connectwise/connectwise.module';
import { CalendarModule } from './modules/calendar/calendar.module';
import { SchedulingModule } from './modules/schedule/schedule.module';
import { User } from './modules/users/entities/user.entity';
import { InboxItem } from './modules/inbox/entities/inbox-item.entity';
import { Task } from './modules/tasks/entities/task.entity';
import { Project } from './modules/projects/entities/project.entity';
import { ConnectWiseConnection } from './modules/connectwise/entities/connectwise-connection.entity';
import { CalendarConnection } from './modules/calendar/entities/calendar-connection.entity';
import { CalendarEvent } from './modules/calendar/entities/calendar-event.entity';
@Module({
imports: [
@@ -28,7 +34,15 @@ import { Project } from './modules/projects/entities/project.entity';
username: configService.get('DB_USERNAME', 'postgres'),
password: configService.get('DB_PASSWORD', 'postgres'),
database: configService.get('DB_DATABASE', 'autoscheduler'),
entities: [User, InboxItem, Task, Project],
entities: [
User,
InboxItem,
Task,
Project,
ConnectWiseConnection,
CalendarConnection,
CalendarEvent,
],
synchronize: configService.get('NODE_ENV') !== 'production',
logging: configService.get('NODE_ENV') !== 'production',
}),
@@ -41,6 +55,9 @@ import { Project } from './modules/projects/entities/project.entity';
TasksModule,
ProjectsModule,
HealthModule,
ConnectWiseModule,
CalendarModule,
SchedulingModule,
],
})
export class AppModule {}

View File

@@ -0,0 +1,73 @@
import {
Controller,
Get,
Post,
Delete,
Param,
Body,
Query,
UseGuards,
Request,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CalendarService } from './calendar.service';
import { CreateCalendarConnectionDto } from './dto/create-calendar-connection.dto';
interface AuthenticatedRequest {
user: { id: string };
}
@Controller('api/v1')
@UseGuards(JwtAuthGuard)
export class CalendarController {
constructor(private readonly calendarService: CalendarService) {}
@Post('connections/calendar')
async createConnection(
@Request() req: AuthenticatedRequest,
@Body() createDto: CreateCalendarConnectionDto,
) {
const connection = await this.calendarService.createConnection(req.user.id, createDto);
return this.calendarService.toConnectionResponseDto(connection);
}
@Get('connections/calendar')
async findAllConnections(@Request() req: AuthenticatedRequest) {
const connections = await this.calendarService.findAllConnections(req.user.id);
return connections.map((c) => this.calendarService.toConnectionResponseDto(c));
}
@Get('connections/calendar/:id')
async findConnectionById(@Request() req: AuthenticatedRequest, @Param('id') id: string) {
const connection = await this.calendarService.findConnectionById(id, req.user.id);
if (!connection) {
return { error: 'Not found' };
}
return this.calendarService.toConnectionResponseDto(connection);
}
@Delete('connections/calendar/:id')
async deleteConnection(@Request() req: AuthenticatedRequest, @Param('id') id: string) {
await this.calendarService.deleteConnection(id, req.user.id);
return { success: true };
}
@Post('connections/calendar/:id/sync')
async syncCalendar(@Request() req: AuthenticatedRequest, @Param('id') id: string) {
const count = await this.calendarService.syncCalendar(id, req.user.id);
return { syncedCount: count };
}
@Get('calendar/events')
async findEvents(
@Request() req: AuthenticatedRequest,
@Query('start') start: string,
@Query('end') end: string,
) {
const startDate = start ? new Date(start) : new Date();
const endDate = end ? new Date(end) : new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
const events = await this.calendarService.findEvents(req.user.id, startDate, endDate);
return events.map((e) => this.calendarService.toEventResponseDto(e));
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CalendarController } from './calendar.controller';
import { CalendarService } from './calendar.service';
import { CalendarConnection } from './entities/calendar-connection.entity';
import { CalendarEvent } from './entities/calendar-event.entity';
@Module({
imports: [TypeOrmModule.forFeature([CalendarConnection, CalendarEvent])],
controllers: [CalendarController],
providers: [CalendarService],
exports: [CalendarService],
})
export class CalendarModule {}

View File

@@ -0,0 +1,383 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Between } from 'typeorm';
import axios from 'axios';
import { CalendarConnection } from './entities/calendar-connection.entity';
import { CalendarEvent } from './entities/calendar-event.entity';
import { CreateCalendarConnectionDto } from './dto/create-calendar-connection.dto';
import { CalendarProvider } from '@nick-tracker/shared-types';
import { addDays, startOfDay, endOfDay } from 'date-fns';
@Injectable()
export class CalendarService {
private readonly logger = new Logger(CalendarService.name);
constructor(
@InjectRepository(CalendarConnection)
private readonly connectionRepository: Repository<CalendarConnection>,
@InjectRepository(CalendarEvent)
private readonly eventRepository: Repository<CalendarEvent>,
) {}
async createConnection(
userId: string,
createDto: CreateCalendarConnectionDto,
): Promise<CalendarConnection> {
const connection = this.connectionRepository.create({
...createDto,
userId,
});
return this.connectionRepository.save(connection);
}
async findAllConnections(userId: string): Promise<CalendarConnection[]> {
return this.connectionRepository.find({ where: { userId } });
}
async findConnectionById(id: string, userId: string): Promise<CalendarConnection | null> {
return this.connectionRepository.findOne({ where: { id, userId } });
}
async deleteConnection(id: string, userId: string): Promise<void> {
const connection = await this.findConnectionById(id, userId);
if (!connection) {
throw new NotFoundException('Calendar connection not found');
}
await this.connectionRepository.remove(connection);
}
async syncCalendar(connectionId: string, userId: string): Promise<number> {
const connection = await this.findConnectionById(connectionId, userId);
if (!connection) {
throw new NotFoundException('Calendar connection not found');
}
let syncedCount = 0;
switch (connection.provider) {
case CalendarProvider.CALDAV:
syncedCount = await this.syncCalDAV(connection, userId);
break;
case CalendarProvider.MICROSOFT_GRAPH:
syncedCount = await this.syncMicrosoftGraph(connection, userId);
break;
case CalendarProvider.GOOGLE:
syncedCount = await this.syncGoogleCalendar(connection, userId);
break;
}
connection.lastSync = new Date();
await this.connectionRepository.save(connection);
return syncedCount;
}
private async syncCalDAV(connection: CalendarConnection, userId: string): Promise<number> {
if (!connection.calendarUrl || !connection.credentials) {
this.logger.warn('CalDAV connection missing URL or credentials');
return 0;
}
const startDate = startOfDay(new Date());
const endDate = endOfDay(addDays(new Date(), 30));
try {
// Build CalDAV REPORT request for calendar-query
const calendarQuery = `<?xml version="1.0" encoding="utf-8" ?>
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<D:getetag/>
<C:calendar-data/>
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:time-range start="${startDate.toISOString().replace(/[-:]/g, '').split('.')[0]}Z"
end="${endDate.toISOString().replace(/[-:]/g, '').split('.')[0]}Z"/>
</C:comp-filter>
</C:comp-filter>
</C:filter>
</C:calendar-query>`;
const authToken = Buffer.from(
`${connection.credentials.username}:${connection.credentials.password}`,
).toString('base64');
const response = await axios({
method: 'REPORT',
url: connection.calendarUrl,
headers: {
Authorization: `Basic ${authToken}`,
'Content-Type': 'application/xml',
Depth: '1',
},
data: calendarQuery,
});
const events = this.parseICalEvents(response.data, connection.provider);
let syncedCount = 0;
for (const event of events) {
await this.upsertEvent(userId, connection.id, event);
syncedCount++;
}
return syncedCount;
} catch (error) {
this.logger.error(`CalDAV sync failed: ${error.message}`);
throw error;
}
}
private async syncMicrosoftGraph(
connection: CalendarConnection,
userId: string,
): Promise<number> {
if (!connection.credentials?.accessToken) {
this.logger.warn('Microsoft Graph connection missing access token');
return 0;
}
const startDate = new Date();
const endDate = addDays(startDate, 30);
try {
const response = await axios.get('https://graph.microsoft.com/v1.0/me/calendarView', {
headers: {
Authorization: `Bearer ${connection.credentials.accessToken}`,
},
params: {
startDateTime: startDate.toISOString(),
endDateTime: endDate.toISOString(),
$select: 'id,subject,start,end,isAllDay',
},
});
const events = response.data.value || [];
let syncedCount = 0;
for (const msEvent of events) {
await this.upsertEvent(userId, connection.id, {
externalId: msEvent.id,
title: msEvent.subject || 'Untitled',
startTime: new Date(msEvent.start.dateTime),
endTime: new Date(msEvent.end.dateTime),
isAllDay: msEvent.isAllDay || false,
source: CalendarProvider.MICROSOFT_GRAPH,
});
syncedCount++;
}
return syncedCount;
} catch (error) {
this.logger.error(`Microsoft Graph sync failed: ${error.message}`);
throw error;
}
}
private async syncGoogleCalendar(
connection: CalendarConnection,
userId: string,
): Promise<number> {
if (!connection.credentials?.accessToken) {
this.logger.warn('Google Calendar connection missing access token');
return 0;
}
const startDate = new Date();
const endDate = addDays(startDate, 30);
try {
const response = await axios.get(
'https://www.googleapis.com/calendar/v3/calendars/primary/events',
{
headers: {
Authorization: `Bearer ${connection.credentials.accessToken}`,
},
params: {
timeMin: startDate.toISOString(),
timeMax: endDate.toISOString(),
singleEvents: true,
},
},
);
const events = response.data.items || [];
let syncedCount = 0;
for (const gEvent of events) {
const startTime = gEvent.start.dateTime
? new Date(gEvent.start.dateTime)
: new Date(gEvent.start.date);
const endTime = gEvent.end.dateTime
? new Date(gEvent.end.dateTime)
: new Date(gEvent.end.date);
const isAllDay = !gEvent.start.dateTime;
await this.upsertEvent(userId, connection.id, {
externalId: gEvent.id,
title: gEvent.summary || 'Untitled',
startTime,
endTime,
isAllDay,
source: CalendarProvider.GOOGLE,
});
syncedCount++;
}
return syncedCount;
} catch (error) {
this.logger.error(`Google Calendar sync failed: ${error.message}`);
throw error;
}
}
private parseICalEvents(
icalData: string,
source: CalendarProvider,
): Array<{
externalId: string;
title: string;
startTime: Date;
endTime: Date;
isAllDay: boolean;
source: CalendarProvider;
}> {
const events: Array<{
externalId: string;
title: string;
startTime: Date;
endTime: Date;
isAllDay: boolean;
source: CalendarProvider;
}> = [];
// Basic iCal parsing - extract VEVENT components
const veventRegex = /BEGIN:VEVENT[\s\S]*?END:VEVENT/g;
const matches = icalData.match(veventRegex) || [];
for (const vevent of matches) {
const uid = this.extractICalProperty(vevent, 'UID');
const summary = this.extractICalProperty(vevent, 'SUMMARY') || 'Untitled';
const dtstart = this.extractICalProperty(vevent, 'DTSTART');
const dtend = this.extractICalProperty(vevent, 'DTEND');
if (uid && dtstart) {
const isAllDay = !dtstart.includes('T');
const startTime = this.parseICalDate(dtstart);
const endTime = dtend
? this.parseICalDate(dtend)
: new Date(startTime.getTime() + 60 * 60 * 1000);
events.push({
externalId: uid,
title: summary,
startTime,
endTime,
isAllDay,
source,
});
}
}
return events;
}
private extractICalProperty(vevent: string, property: string): string | null {
const regex = new RegExp(`${property}[^:]*:([^\\r\\n]+)`, 'i');
const match = vevent.match(regex);
return match ? match[1].trim() : null;
}
private parseICalDate(dateStr: string): Date {
// Handle both date-only and datetime formats
if (dateStr.includes('T')) {
// DateTime format: 20240115T120000Z or 20240115T120000
const year = parseInt(dateStr.substring(0, 4));
const month = parseInt(dateStr.substring(4, 6)) - 1;
const day = parseInt(dateStr.substring(6, 8));
const hour = parseInt(dateStr.substring(9, 11));
const minute = parseInt(dateStr.substring(11, 13));
const second = parseInt(dateStr.substring(13, 15)) || 0;
if (dateStr.endsWith('Z')) {
return new Date(Date.UTC(year, month, day, hour, minute, second));
}
return new Date(year, month, day, hour, minute, second);
} else {
// Date-only format: 20240115
const year = parseInt(dateStr.substring(0, 4));
const month = parseInt(dateStr.substring(4, 6)) - 1;
const day = parseInt(dateStr.substring(6, 8));
return new Date(year, month, day);
}
}
private async upsertEvent(
userId: string,
connectionId: string,
eventData: {
externalId: string;
title: string;
startTime: Date;
endTime: Date;
isAllDay: boolean;
source: CalendarProvider;
},
): Promise<CalendarEvent> {
let event = await this.eventRepository.findOne({
where: {
userId,
externalId: eventData.externalId,
source: eventData.source,
},
});
if (event) {
Object.assign(event, eventData);
} else {
event = this.eventRepository.create({
...eventData,
userId,
connectionId,
});
}
return this.eventRepository.save(event);
}
async findEvents(userId: string, startDate: Date, endDate: Date): Promise<CalendarEvent[]> {
return this.eventRepository.find({
where: {
userId,
startTime: Between(startDate, endDate),
},
order: { startTime: 'ASC' },
});
}
toConnectionResponseDto(connection: CalendarConnection) {
return {
id: connection.id,
provider: connection.provider,
calendarUrl: connection.calendarUrl ?? undefined,
lastSync: connection.lastSync?.toISOString(),
createdAt: connection.createdAt.toISOString(),
updatedAt: connection.updatedAt.toISOString(),
};
}
toEventResponseDto(event: CalendarEvent) {
return {
id: event.id,
externalId: event.externalId,
title: event.title,
startTime: event.startTime.toISOString(),
endTime: event.endTime.toISOString(),
isAllDay: event.isAllDay,
source: event.source,
createdAt: event.createdAt.toISOString(),
updatedAt: event.updatedAt.toISOString(),
};
}
}

View File

@@ -0,0 +1,35 @@
import { IsEnum, IsOptional, IsString, IsUrl, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { CalendarProvider } from '@nick-tracker/shared-types';
class CredentialsDto {
@IsString()
@IsOptional()
username?: string;
@IsString()
@IsOptional()
password?: string;
@IsString()
@IsOptional()
accessToken?: string;
@IsString()
@IsOptional()
refreshToken?: string;
}
export class CreateCalendarConnectionDto {
@IsEnum(CalendarProvider)
provider: CalendarProvider;
@IsUrl()
@IsOptional()
calendarUrl?: string;
@ValidateNested()
@Type(() => CredentialsDto)
@IsOptional()
credentials?: CredentialsDto;
}

View File

@@ -0,0 +1,50 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
import { CalendarProvider } from '@nick-tracker/shared-types';
@Entity('calendar_connections')
export class CalendarConnection {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({
type: 'enum',
enum: CalendarProvider,
})
provider: CalendarProvider;
@Column({ nullable: true })
calendarUrl: string | null;
@Column({ type: 'jsonb', nullable: true })
credentials: {
username?: string;
password?: string;
accessToken?: string;
refreshToken?: string;
} | null;
@Column({ type: 'timestamp', nullable: true })
lastSync: Date | null;
@Column({ type: 'uuid' })
userId: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'userId' })
user: User;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@@ -0,0 +1,59 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
import { CalendarConnection } from './calendar-connection.entity';
import { CalendarProvider } from '@nick-tracker/shared-types';
@Entity('calendar_events')
export class CalendarEvent {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
externalId: string;
@Column()
title: string;
@Column({ type: 'timestamp' })
startTime: Date;
@Column({ type: 'timestamp' })
endTime: Date;
@Column({ default: false })
isAllDay: boolean;
@Column({
type: 'enum',
enum: CalendarProvider,
})
source: CalendarProvider;
@Column({ type: 'uuid' })
userId: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'userId' })
user: User;
@Column({ type: 'uuid', nullable: true })
connectionId: string | null;
@ManyToOne(() => CalendarConnection, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'connectionId' })
connection: CalendarConnection | null;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@@ -0,0 +1,50 @@
import { Controller, Get, Post, Delete, Param, Body, UseGuards, Request } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { ConnectWiseService } from './connectwise.service';
import { CreateConnectWiseConnectionDto } from './dto/create-connectwise-connection.dto';
interface AuthenticatedRequest {
user: { id: string };
}
@Controller('api/v1/connections/connectwise')
@UseGuards(JwtAuthGuard)
export class ConnectWiseController {
constructor(private readonly connectWiseService: ConnectWiseService) {}
@Post()
async create(
@Request() req: AuthenticatedRequest,
@Body() createDto: CreateConnectWiseConnectionDto,
) {
const connection = await this.connectWiseService.create(req.user.id, createDto);
return this.connectWiseService.toResponseDto(connection);
}
@Get()
async findAll(@Request() req: AuthenticatedRequest) {
const connections = await this.connectWiseService.findAll(req.user.id);
return connections.map((c) => this.connectWiseService.toResponseDto(c));
}
@Get(':id')
async findById(@Request() req: AuthenticatedRequest, @Param('id') id: string) {
const connection = await this.connectWiseService.findById(id, req.user.id);
if (!connection) {
return { error: 'Not found' };
}
return this.connectWiseService.toResponseDto(connection);
}
@Delete(':id')
async delete(@Request() req: AuthenticatedRequest, @Param('id') id: string) {
await this.connectWiseService.delete(id, req.user.id);
return { success: true };
}
@Post(':id/sync')
async sync(@Request() req: AuthenticatedRequest, @Param('id') id: string) {
const count = await this.connectWiseService.syncTickets(id, req.user.id);
return { syncedCount: count };
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConnectWiseController } from './connectwise.controller';
import { ConnectWiseService } from './connectwise.service';
import { ConnectWiseConnection } from './entities/connectwise-connection.entity';
import { InboxModule } from '../inbox/inbox.module';
@Module({
imports: [TypeOrmModule.forFeature([ConnectWiseConnection]), InboxModule],
controllers: [ConnectWiseController],
providers: [ConnectWiseService],
exports: [ConnectWiseService],
})
export class ConnectWiseModule {}

View File

@@ -0,0 +1,200 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import axios, { AxiosInstance } from 'axios';
import { ConnectWiseConnection } from './entities/connectwise-connection.entity';
import { CreateConnectWiseConnectionDto } from './dto/create-connectwise-connection.dto';
import { InboxService } from '../inbox/inbox.service';
import { InboxSource } from '@nick-tracker/shared-types';
interface ConnectWiseTicket {
id: number;
summary: string;
priority?: { name?: string };
sla?: { name?: string };
status?: { name?: string };
company?: { name?: string };
project?: { id: number; name: string };
}
interface ConnectWiseProject {
id: number;
name: string;
status?: { name?: string };
company?: { name?: string };
}
@Injectable()
export class ConnectWiseService {
private readonly logger = new Logger(ConnectWiseService.name);
constructor(
@InjectRepository(ConnectWiseConnection)
private readonly connectionRepository: Repository<ConnectWiseConnection>,
private readonly inboxService: InboxService,
) {}
async create(
userId: string,
createDto: CreateConnectWiseConnectionDto,
): Promise<ConnectWiseConnection> {
const connection = this.connectionRepository.create({
...createDto,
userId,
});
return this.connectionRepository.save(connection);
}
async findAll(userId: string): Promise<ConnectWiseConnection[]> {
return this.connectionRepository.find({ where: { userId } });
}
async findById(id: string, userId: string): Promise<ConnectWiseConnection | null> {
return this.connectionRepository.findOne({ where: { id, userId } });
}
async delete(id: string, userId: string): Promise<void> {
const connection = await this.findById(id, userId);
if (!connection) {
throw new NotFoundException('ConnectWise connection not found');
}
await this.connectionRepository.remove(connection);
}
private createApiClient(connection: ConnectWiseConnection): AxiosInstance {
const authToken = Buffer.from(
`${connection.companyId}+${connection.publicKey}:${connection.privateKey}`,
).toString('base64');
return axios.create({
baseURL: connection.apiUrl,
headers: {
Authorization: `Basic ${authToken}`,
'Content-Type': 'application/json',
clientId: connection.companyId,
},
});
}
async syncTickets(connectionId: string, userId: string): Promise<number> {
const connection = await this.findById(connectionId, userId);
if (!connection) {
throw new NotFoundException('ConnectWise connection not found');
}
const client = this.createApiClient(connection);
let syncedCount = 0;
try {
// Sync service tickets assigned to member
const serviceTicketsResponse = await client.get('/service/tickets', {
params: {
conditions: `resources/id=${connection.memberId}`,
pageSize: 100,
},
});
const serviceTickets: ConnectWiseTicket[] = serviceTicketsResponse.data || [];
for (const ticket of serviceTickets) {
await this.createInboxItemFromTicket(userId, ticket, 'service');
syncedCount++;
}
// Sync project tickets assigned to member
const projectTicketsResponse = await client.get('/project/tickets', {
params: {
conditions: `resources/id=${connection.memberId}`,
pageSize: 100,
},
});
const projectTickets: ConnectWiseTicket[] = projectTicketsResponse.data || [];
for (const ticket of projectTickets) {
await this.createInboxItemFromTicket(userId, ticket, 'project');
syncedCount++;
}
// Sync projects assigned to member and check for zero-ticket projects
const projectsResponse = await client.get('/project/projects', {
params: {
conditions: `manager/id=${connection.memberId}`,
pageSize: 100,
},
});
const projects: ConnectWiseProject[] = projectsResponse.data || [];
for (const project of projects) {
// Check if project has any tickets
const ticketsResponse = await client.get(`/project/projects/${project.id}/tickets`, {
params: { pageSize: 1 },
});
const hasTickets = (ticketsResponse.data?.length || 0) > 0;
if (!hasTickets) {
// Create planning task for zero-ticket project
await this.inboxService.create(
userId,
{
content: `Plan project: ${project.name}`,
sourceMetadata: {
connectwiseProjectId: project.id,
projectName: project.name,
company: project.company?.name,
status: project.status?.name,
type: 'planning',
},
},
InboxSource.CONNECTWISE,
);
syncedCount++;
}
}
// Update last sync timestamp
connection.lastSync = new Date();
await this.connectionRepository.save(connection);
this.logger.log(`Synced ${syncedCount} items from ConnectWise for user ${userId}`);
return syncedCount;
} catch (error) {
this.logger.error(`Failed to sync ConnectWise tickets: ${error.message}`);
throw error;
}
}
private async createInboxItemFromTicket(
userId: string,
ticket: ConnectWiseTicket,
ticketType: 'service' | 'project',
): Promise<void> {
await this.inboxService.create(
userId,
{
content: ticket.summary,
sourceMetadata: {
ticketId: ticket.id,
ticketType,
priority: ticket.priority?.name,
sla: ticket.sla?.name,
status: ticket.status?.name,
company: ticket.company?.name,
projectId: ticket.project?.id,
projectName: ticket.project?.name,
},
},
InboxSource.CONNECTWISE,
);
}
toResponseDto(connection: ConnectWiseConnection) {
return {
id: connection.id,
companyId: connection.companyId,
apiUrl: connection.apiUrl,
memberId: connection.memberId,
lastSync: connection.lastSync?.toISOString(),
createdAt: connection.createdAt.toISOString(),
updatedAt: connection.updatedAt.toISOString(),
};
}
}

View File

@@ -0,0 +1,23 @@
import { IsString, IsNotEmpty, IsUrl } from 'class-validator';
export class CreateConnectWiseConnectionDto {
@IsString()
@IsNotEmpty()
companyId: string;
@IsString()
@IsNotEmpty()
publicKey: string;
@IsString()
@IsNotEmpty()
privateKey: string;
@IsUrl()
@IsNotEmpty()
apiUrl: string;
@IsString()
@IsNotEmpty()
memberId: string;
}

View File

@@ -0,0 +1,47 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
@Entity('connectwise_connections')
export class ConnectWiseConnection {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
companyId: string;
@Column()
publicKey: string;
@Column()
privateKey: string;
@Column()
apiUrl: string;
@Column()
memberId: string;
@Column({ type: 'timestamp', nullable: true })
lastSync: Date | null;
@Column({ type: 'uuid' })
userId: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'userId' })
user: User;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@@ -4,7 +4,7 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CreateInboxItemDto } from './dto/create-inbox-item.dto';
import { ProcessInboxItemDto } from './dto/process-inbox-item.dto';
@Controller('inbox')
@Controller('api/v1/inbox')
@UseGuards(JwtAuthGuard)
export class InboxController {
constructor(private readonly inboxService: InboxService) {}
@@ -20,12 +20,7 @@ export class InboxController {
@Request() req: { user: { userId: string } },
@Body() createDto: CreateInboxItemDto,
) {
const item = await this.inboxService.create(
req.user.userId,
createDto.content,
undefined,
createDto.sourceMetadata,
);
const item = await this.inboxService.create(req.user.userId, createDto);
return this.inboxService.toResponseDto(item);
}

View File

@@ -59,7 +59,7 @@ describe('InboxService', () => {
describe('create', () => {
it('should create an inbox item', async () => {
const result = await service.create('test-user-id', 'Test content');
const result = await service.create('test-user-id', { content: 'Test content' });
expect(repository.create).toHaveBeenCalledWith({
userId: 'test-user-id',

View File

@@ -23,15 +23,14 @@ export class InboxService {
async create(
userId: string,
content: string,
dto: { content: string; sourceMetadata?: Record<string, unknown> },
source: InboxSource = InboxSource.MANUAL,
sourceMetadata?: Record<string, unknown>,
): Promise<InboxItem> {
const item = this.inboxRepository.create({
userId,
content,
content: dto.content,
source,
sourceMetadata: sourceMetadata ?? null,
sourceMetadata: dto.sourceMetadata ?? null,
processed: false,
});
return this.inboxRepository.save(item);

View File

@@ -0,0 +1,32 @@
import { Controller, Post, Delete, UseGuards, Request } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { ScheduleService } from './schedule.service';
import { TasksService } from '../tasks/tasks.service';
interface AuthenticatedRequest {
user: { id: string };
}
@Controller('api/v1/schedule')
@UseGuards(JwtAuthGuard)
export class ScheduleController {
constructor(
private readonly scheduleService: ScheduleService,
private readonly tasksService: TasksService,
) {}
@Post('regenerate')
async regenerate(@Request() req: AuthenticatedRequest) {
const result = await this.scheduleService.regenerateSchedule(req.user.id);
return {
scheduledCount: result.scheduledCount,
tasks: result.tasks.map((t) => this.tasksService.toResponseDto(t)),
};
}
@Delete('clear')
async clear(@Request() req: AuthenticatedRequest) {
const clearedCount = await this.scheduleService.clearSchedule(req.user.id);
return { clearedCount };
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ScheduleController } from './schedule.controller';
import { ScheduleService } from './schedule.service';
import { Task } from '../tasks/entities/task.entity';
import { CalendarEvent } from '../calendar/entities/calendar-event.entity';
import { User } from '../users/entities/user.entity';
import { TasksModule } from '../tasks/tasks.module';
@Module({
imports: [TypeOrmModule.forFeature([Task, CalendarEvent, User]), TasksModule],
controllers: [ScheduleController],
providers: [ScheduleService],
exports: [ScheduleService],
})
export class SchedulingModule {}

View File

@@ -0,0 +1,265 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Not, IsNull } from 'typeorm';
import {
addMinutes,
startOfDay,
addDays,
setHours,
setMinutes,
isAfter,
isBefore,
areIntervalsOverlapping,
getDay,
} from 'date-fns';
import { Task } from '../tasks/entities/task.entity';
import { CalendarEvent } from '../calendar/entities/calendar-event.entity';
import { User } from '../users/entities/user.entity';
import { TaskStatus, type WorkingHours } from '@nick-tracker/shared-types';
interface TimeSlot {
start: Date;
end: Date;
}
const DEFAULT_WORKING_HOURS: WorkingHours = {
monday: { start: '09:00', end: '17:00' },
tuesday: { start: '09:00', end: '17:00' },
wednesday: { start: '09:00', end: '17:00' },
thursday: { start: '09:00', end: '17:00' },
friday: { start: '09:00', end: '17:00' },
};
const DAY_NAMES = [
'sunday',
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
] as const;
@Injectable()
export class ScheduleService {
private readonly logger = new Logger(ScheduleService.name);
constructor(
@InjectRepository(Task)
private readonly taskRepository: Repository<Task>,
@InjectRepository(CalendarEvent)
private readonly eventRepository: Repository<CalendarEvent>,
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {}
async regenerateSchedule(userId: string): Promise<{ scheduledCount: number; tasks: Task[] }> {
const user = await this.userRepository.findOne({ where: { id: userId } });
if (!user) {
throw new Error('User not found');
}
const workingHours = user.workingHours || DEFAULT_WORKING_HOURS;
// Get unscheduled NEXT_ACTION tasks that are not locked
const unscheduledTasks = await this.taskRepository.find({
where: {
userId,
status: TaskStatus.NEXT_ACTION,
isLocked: false,
scheduledStart: IsNull(),
},
order: {
priority: { direction: 'ASC', nulls: 'LAST' },
dueDate: { direction: 'ASC', nulls: 'LAST' },
createdAt: 'ASC',
},
});
if (unscheduledTasks.length === 0) {
return { scheduledCount: 0, tasks: [] };
}
// Get existing calendar events for the next 14 days
const startDate = new Date();
const endDate = addDays(startDate, 14);
const calendarEvents = await this.eventRepository.find({
where: {
userId,
},
});
// Get already scheduled tasks (locked ones)
const scheduledTasks = await this.taskRepository.find({
where: {
userId,
scheduledStart: Not(IsNull()),
},
});
// Build list of blocked time slots
const blockedSlots: TimeSlot[] = [
...calendarEvents.map((e) => ({ start: e.startTime, end: e.endTime })),
...scheduledTasks
.filter((t) => t.scheduledStart && t.scheduledEnd)
.map((t) => ({ start: t.scheduledStart!, end: t.scheduledEnd! })),
];
// Group tasks by context for batching
const tasksByContext = this.groupTasksByContext(unscheduledTasks);
const scheduledResults: Task[] = [];
// Process each context group
for (const [, tasks] of Object.entries(tasksByContext)) {
// Sort by priority and due date within context
const sortedTasks = tasks.sort((a, b) => {
if (a.priority !== null && b.priority !== null) {
return a.priority - b.priority;
}
if (a.priority !== null) return -1;
if (b.priority !== null) return 1;
if (a.dueDate && b.dueDate) {
return a.dueDate.getTime() - b.dueDate.getTime();
}
if (a.dueDate) return -1;
if (b.dueDate) return 1;
return 0;
});
for (const task of sortedTasks) {
const slot = this.findNextAvailableSlot(
blockedSlots,
workingHours,
task.estimatedDuration || 30,
startDate,
endDate,
);
if (slot) {
task.scheduledStart = slot.start;
task.scheduledEnd = slot.end;
await this.taskRepository.save(task);
scheduledResults.push(task);
// Add this slot to blocked slots for next iteration
blockedSlots.push(slot);
}
}
}
this.logger.log(`Scheduled ${scheduledResults.length} tasks for user ${userId}`);
return { scheduledCount: scheduledResults.length, tasks: scheduledResults };
}
private groupTasksByContext(tasks: Task[]): Record<string, Task[]> {
const groups: Record<string, Task[]> = {};
for (const task of tasks) {
const context = task.context || 'ANYWHERE';
if (!groups[context]) {
groups[context] = [];
}
groups[context].push(task);
}
return groups;
}
private findNextAvailableSlot(
blockedSlots: TimeSlot[],
workingHours: WorkingHours,
durationMinutes: number,
searchStart: Date,
searchEnd: Date,
): TimeSlot | null {
let currentDate = startOfDay(searchStart);
const maxIterations = 100;
let iterations = 0;
while (isBefore(currentDate, searchEnd) && iterations < maxIterations) {
iterations++;
const dayOfWeek = getDay(currentDate);
const dayName = DAY_NAMES[dayOfWeek];
const dayHours = workingHours[dayName];
if (!dayHours) {
currentDate = addDays(currentDate, 1);
continue;
}
// Parse working hours
const [startHour, startMin] = dayHours.start.split(':').map(Number);
const [endHour, endMin] = dayHours.end.split(':').map(Number);
let slotStart = setMinutes(setHours(currentDate, startHour), startMin);
const dayEnd = setMinutes(setHours(currentDate, endHour), endMin);
// If searching from today, start from now if we're within working hours
if (
currentDate.toDateString() === searchStart.toDateString() &&
isAfter(searchStart, slotStart)
) {
slotStart = this.roundToNext15Minutes(searchStart);
}
// Try to find a slot in this day
while (isBefore(slotStart, dayEnd)) {
const slotEnd = addMinutes(slotStart, durationMinutes);
// Check if slot ends within working hours
if (isAfter(slotEnd, dayEnd)) {
break;
}
// Check if slot overlaps with any blocked slot
const slot: TimeSlot = { start: slotStart, end: slotEnd };
const hasConflict = blockedSlots.some((blocked) =>
areIntervalsOverlapping(
{ start: slot.start, end: slot.end },
{ start: blocked.start, end: blocked.end },
),
);
if (!hasConflict) {
// Check context constraints (e.g., work contexts during work hours)
if (this.isValidContextSlot()) {
return slot;
}
}
// Move to next 15-minute slot
slotStart = addMinutes(slotStart, 15);
}
// Move to next day
currentDate = addDays(currentDate, 1);
}
return null;
}
private roundToNext15Minutes(date: Date): Date {
const minutes = date.getMinutes();
const remainder = minutes % 15;
if (remainder === 0) {
return date;
}
return addMinutes(date, 15 - remainder);
}
private isValidContextSlot(): boolean {
// All contexts are valid during working hours for now
// Could add more specific rules later (e.g., phone calls only 9-5)
return true;
}
async clearSchedule(userId: string): Promise<number> {
const result = await this.taskRepository.update(
{ userId, isLocked: false, scheduledStart: Not(IsNull()) },
{ scheduledStart: null, scheduledEnd: null },
);
return result.affected || 0;
}
}

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">
<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.
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>

View File

@@ -227,6 +227,71 @@ export interface ProjectResponseDto {
updatedAt: string;
}
// DTOs - ConnectWise Connection
export interface CreateConnectWiseConnectionDto {
companyId: string;
publicKey: string;
privateKey: string;
apiUrl: string;
memberId: string;
}
export interface ConnectWiseConnectionResponseDto {
id: string;
companyId: string;
apiUrl: string;
memberId: string;
lastSync?: string;
createdAt: string;
updatedAt: string;
}
// DTOs - Calendar Connection
export enum CalendarProvider {
CALDAV = 'CALDAV',
MICROSOFT_GRAPH = 'MICROSOFT_GRAPH',
GOOGLE = 'GOOGLE',
}
export interface CreateCalendarConnectionDto {
provider: CalendarProvider;
calendarUrl?: string;
credentials?: {
username?: string;
password?: string;
accessToken?: string;
refreshToken?: string;
};
}
export interface CalendarConnectionResponseDto {
id: string;
provider: CalendarProvider;
calendarUrl?: string;
lastSync?: string;
createdAt: string;
updatedAt: string;
}
// DTOs - Calendar Events
export interface CalendarEventResponseDto {
id: string;
externalId: string;
title: string;
startTime: string;
endTime: string;
isAllDay: boolean;
source: CalendarProvider;
createdAt: string;
updatedAt: string;
}
// DTOs - Schedule
export interface ScheduleRegenerateResponseDto {
scheduledCount: number;
tasks: TaskResponseDto[];
}
// Health check
export interface HealthCheckResponseDto {
status: 'ok' | 'error';

View File

@@ -26,7 +26,7 @@
"name": "ConnectWise Manage Integration",
"description": "Read-only sync from ConnectWise Manage that imports service tickets, project tickets, and projects assigned to user. Projects with zero tickets surface as planning tasks. ConnectWise priority/SLA displayed for reference only; user assigns manual priority",
"priority": 3,
"passes": false,
"passes": true,
"acceptance": "ConnectWise API integration syncs assigned service tickets as inbox items"
},
{
@@ -35,7 +35,7 @@
"name": "Intelligent Calendar Scheduling",
"description": "Automatic scheduling engine that pulls from CalDAV calendars (Nextcloud, Google Calendar, Outlook via Microsoft Graph) and places actionable tasks into available time slots, respecting working hours, context constraints, deadlines, and manual priority. Supports drag-drop manual override and task locking",
"priority": 4,
"passes": false,
"passes": true,
"acceptance": "Engine reads existing events from CalDAV/Google/Outlook calendars"
},
{
@@ -44,7 +44,7 @@
"name": "Interactive Calendar Week View",
"description": "React SPA with interactive week-view calendar displaying scheduled tasks and calendar events. Supports drag-and-drop task rescheduling, manual time adjustments, and real-time updates when scheduling changes occur",
"priority": 5,
"passes": false,
"passes": true,
"acceptance": "Week view renders all scheduled tasks and synced calendar events"
},
{

View File

@@ -19,3 +19,32 @@
- Inbox UI with quick-add form and GTD processing workflow modal
- All tests passing, build succeeds, lint passes (one warning)
[2026-01-11T09:58:00.000Z] [2] [COMPLETE] - Phase 2 Core implemented
- ConnectWise Manage Integration:
- ConnectWiseModule with service, controller, entity
- API endpoints: POST/GET/DELETE /api/v1/connections/connectwise
- Sync endpoint: POST /api/v1/connections/connectwise/:id/sync
- Syncs service tickets, project tickets, and zero-ticket projects to inbox
- ConnectWise priority/SLA stored in task metadata
- Intelligent Calendar Scheduling:
- CalendarModule with calendar connection and event entities
- CalendarConnection entity supports CALDAV, MICROSOFT_GRAPH, GOOGLE providers
- CalendarEvent entity stores synced external calendar events
- CalendarService with CalDAV, Microsoft Graph, Google Calendar sync methods
- API endpoints: POST/GET/DELETE /api/v1/connections/calendar
- Sync endpoint: POST /api/v1/connections/calendar/:id/sync
- Events endpoint: GET /api/v1/calendar/events
- SchedulingModule with schedule regeneration engine
- ScheduleService finds available time slots respecting working hours
- Groups tasks by context for batching, respects priority and due dates
- Schedule endpoint: POST /api/v1/schedule/regenerate
- Interactive Calendar Week View:
- FullCalendar integration with timeGridWeek view
- Drag-and-drop task rescheduling with eventDrop/eventResize handlers
- Tasks auto-lock when manually moved
- Color-coded by context (@desk=blue, @phone=green, @errand=orange, @homelab=purple, @anywhere=gray)
- Calendar events displayed with distinct styling
- Regenerate Schedule button triggers scheduling engine
- ToastProvider for user feedback notifications
- All tests passing (17 tests), build succeeds, lint passes (2 warnings)