@auxiora/calendar-intelligence 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +191 -0
- package/dist/analyzer.d.ts +9 -0
- package/dist/analyzer.d.ts.map +1 -0
- package/dist/analyzer.js +103 -0
- package/dist/analyzer.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/meeting-prep.d.ts +9 -0
- package/dist/meeting-prep.d.ts.map +1 -0
- package/dist/meeting-prep.js +71 -0
- package/dist/meeting-prep.js.map +1 -0
- package/dist/optimizer.d.ts +7 -0
- package/dist/optimizer.d.ts.map +1 -0
- package/dist/optimizer.js +61 -0
- package/dist/optimizer.js.map +1 -0
- package/dist/types.d.ts +68 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +26 -0
- package/src/analyzer.ts +129 -0
- package/src/index.ts +4 -0
- package/src/meeting-prep.ts +86 -0
- package/src/optimizer.ts +71 -0
- package/src/types.ts +75 -0
- package/tests/analyzer.test.ts +150 -0
- package/tests/meeting-prep.test.ts +90 -0
- package/tests/optimizer.test.ts +79 -0
- package/tsconfig.json +5 -0
- package/tsconfig.tsbuildinfo +1 -0
package/src/analyzer.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { AnalyzerConfig, CalendarEvent, ConflictInfo, ScheduleAnalysis, TimeSlot } from './types.js';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_CONFIG: Required<AnalyzerConfig> = {
|
|
4
|
+
workdayStartHour: 9,
|
|
5
|
+
workdayEndHour: 17,
|
|
6
|
+
focusBlockMinMinutes: 60,
|
|
7
|
+
bufferBetweenMeetingsMinutes: 5,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export class ScheduleAnalyzer {
|
|
11
|
+
private config: Required<AnalyzerConfig>;
|
|
12
|
+
|
|
13
|
+
constructor(config?: AnalyzerConfig) {
|
|
14
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
analyzeDay(events: CalendarEvent[], date: string): ScheduleAnalysis {
|
|
18
|
+
const dayEvents = events
|
|
19
|
+
.filter((e) => e.start.substring(0, 10) === date)
|
|
20
|
+
.sort((a, b) => a.start.localeCompare(b.start));
|
|
21
|
+
|
|
22
|
+
// Detect timezone suffix from events to keep workday bounds consistent
|
|
23
|
+
const tzSuffix = dayEvents.length > 0 && dayEvents[0].start.endsWith('Z') ? 'Z' : '';
|
|
24
|
+
const workdayStart = `${date}T${String(this.config.workdayStartHour).padStart(2, '0')}:00:00${tzSuffix}`;
|
|
25
|
+
const workdayEnd = `${date}T${String(this.config.workdayEndHour).padStart(2, '0')}:00:00${tzSuffix}`;
|
|
26
|
+
|
|
27
|
+
const freeSlots = this.findFreeSlots(dayEvents, workdayStart, workdayEnd);
|
|
28
|
+
const conflicts = this.detectConflicts(dayEvents);
|
|
29
|
+
const focusBlocks = freeSlots.filter((s) => s.durationMinutes >= this.config.focusBlockMinMinutes);
|
|
30
|
+
|
|
31
|
+
const meetingLoadHours = dayEvents.reduce((sum, e) => {
|
|
32
|
+
const durationMs = new Date(e.end).getTime() - new Date(e.start).getTime();
|
|
33
|
+
return sum + durationMs / (1000 * 60 * 60);
|
|
34
|
+
}, 0);
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
date,
|
|
38
|
+
events: dayEvents,
|
|
39
|
+
freeSlots,
|
|
40
|
+
conflicts,
|
|
41
|
+
focusBlocks,
|
|
42
|
+
meetingLoadHours,
|
|
43
|
+
eventCount: dayEvents.length,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
findFreeSlots(
|
|
48
|
+
events: CalendarEvent[],
|
|
49
|
+
rangeStart: string,
|
|
50
|
+
rangeEnd: string,
|
|
51
|
+
minDurationMinutes?: number,
|
|
52
|
+
): TimeSlot[] {
|
|
53
|
+
const sorted = [...events].sort((a, b) => a.start.localeCompare(b.start));
|
|
54
|
+
const slots: TimeSlot[] = [];
|
|
55
|
+
let cursor = new Date(rangeStart).getTime();
|
|
56
|
+
const end = new Date(rangeEnd).getTime();
|
|
57
|
+
|
|
58
|
+
for (const event of sorted) {
|
|
59
|
+
const eventStart = new Date(event.start).getTime();
|
|
60
|
+
const eventEnd = new Date(event.end).getTime();
|
|
61
|
+
|
|
62
|
+
// Skip events entirely outside the range
|
|
63
|
+
if (eventEnd <= new Date(rangeStart).getTime() || eventStart >= end) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (eventStart > cursor) {
|
|
68
|
+
const gapEnd = Math.min(eventStart, end);
|
|
69
|
+
const durationMinutes = (gapEnd - cursor) / (1000 * 60);
|
|
70
|
+
slots.push({
|
|
71
|
+
start: new Date(cursor).toISOString(),
|
|
72
|
+
end: new Date(gapEnd).toISOString(),
|
|
73
|
+
durationMinutes,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
cursor = Math.max(cursor, eventEnd);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Gap after last event
|
|
81
|
+
if (cursor < end) {
|
|
82
|
+
const durationMinutes = (end - cursor) / (1000 * 60);
|
|
83
|
+
slots.push({
|
|
84
|
+
start: new Date(cursor).toISOString(),
|
|
85
|
+
end: new Date(end).toISOString(),
|
|
86
|
+
durationMinutes,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (minDurationMinutes !== undefined) {
|
|
91
|
+
return slots.filter((s) => s.durationMinutes >= minDurationMinutes);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return slots;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
detectConflicts(events: CalendarEvent[]): ConflictInfo[] {
|
|
98
|
+
const sorted = [...events].sort((a, b) => a.start.localeCompare(b.start));
|
|
99
|
+
const conflicts: ConflictInfo[] = [];
|
|
100
|
+
|
|
101
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
102
|
+
for (let j = i + 1; j < sorted.length; j++) {
|
|
103
|
+
const e1 = sorted[i];
|
|
104
|
+
const e2 = sorted[j];
|
|
105
|
+
|
|
106
|
+
const start1 = new Date(e1.start).getTime();
|
|
107
|
+
const end1 = new Date(e1.end).getTime();
|
|
108
|
+
const start2 = new Date(e2.start).getTime();
|
|
109
|
+
const end2 = new Date(e2.end).getTime();
|
|
110
|
+
|
|
111
|
+
if (start1 < end2 && start2 < end1) {
|
|
112
|
+
const overlapStart = Math.max(start1, start2);
|
|
113
|
+
const overlapEnd = Math.min(end1, end2);
|
|
114
|
+
conflicts.push({
|
|
115
|
+
event1Id: e1.id,
|
|
116
|
+
event2Id: e2.id,
|
|
117
|
+
event1Subject: e1.subject,
|
|
118
|
+
event2Subject: e2.subject,
|
|
119
|
+
overlapStart: new Date(overlapStart).toISOString(),
|
|
120
|
+
overlapEnd: new Date(overlapEnd).toISOString(),
|
|
121
|
+
overlapMinutes: (overlapEnd - overlapStart) / (1000 * 60),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return conflicts;
|
|
128
|
+
}
|
|
129
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { AttendeeContext, CalendarEvent, MeetingBrief } from './types.js';
|
|
2
|
+
|
|
3
|
+
export class MeetingPrepGenerator {
|
|
4
|
+
generateBrief(event: CalendarEvent, attendeeContexts?: AttendeeContext[]): MeetingBrief {
|
|
5
|
+
const agenda = this.extractAgenda(event.body);
|
|
6
|
+
const attendees = this.buildAttendeeList(event, attendeeContexts);
|
|
7
|
+
const suggestedTopics = this.generateTopics(event.subject);
|
|
8
|
+
|
|
9
|
+
return {
|
|
10
|
+
eventId: event.id,
|
|
11
|
+
subject: event.subject,
|
|
12
|
+
startTime: event.start,
|
|
13
|
+
attendees,
|
|
14
|
+
agenda: agenda || undefined,
|
|
15
|
+
suggestedTopics,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
getUpcoming(events: CalendarEvent[], withinMinutes: number, now?: Date): CalendarEvent[] {
|
|
20
|
+
const currentTime = (now ?? new Date()).getTime();
|
|
21
|
+
const windowEnd = currentTime + withinMinutes * 60 * 1000;
|
|
22
|
+
|
|
23
|
+
return events.filter((event) => {
|
|
24
|
+
const eventStart = new Date(event.start).getTime();
|
|
25
|
+
return eventStart > currentTime && eventStart <= windowEnd;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private extractAgenda(body?: string): string | null {
|
|
30
|
+
if (!body) return null;
|
|
31
|
+
|
|
32
|
+
const lines = body.split('\n');
|
|
33
|
+
const agendaItems: string[] = [];
|
|
34
|
+
|
|
35
|
+
for (const line of lines) {
|
|
36
|
+
const trimmed = line.trim();
|
|
37
|
+
if (/^[-*]\s+/.test(trimmed) || /^\d+[.)]\s+/.test(trimmed)) {
|
|
38
|
+
agendaItems.push(trimmed);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return agendaItems.length > 0 ? agendaItems.join('\n') : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private buildAttendeeList(
|
|
46
|
+
event: CalendarEvent,
|
|
47
|
+
contexts?: AttendeeContext[],
|
|
48
|
+
): AttendeeContext[] {
|
|
49
|
+
const contextMap = new Map<string, AttendeeContext>();
|
|
50
|
+
if (contexts) {
|
|
51
|
+
for (const ctx of contexts) {
|
|
52
|
+
contextMap.set(ctx.email, ctx);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return event.attendees.map((attendee) => {
|
|
57
|
+
const ctx = contextMap.get(attendee.email);
|
|
58
|
+
return {
|
|
59
|
+
email: attendee.email,
|
|
60
|
+
name: attendee.name ?? ctx?.name,
|
|
61
|
+
lastInteraction: ctx?.lastInteraction,
|
|
62
|
+
relationship: ctx?.relationship,
|
|
63
|
+
notes: ctx?.notes,
|
|
64
|
+
};
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private generateTopics(subject: string): string[] {
|
|
69
|
+
const stopWords = new Set([
|
|
70
|
+
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',
|
|
71
|
+
'of', 'with', 'by', 'from', 'is', 'are', 'was', 'were', 'be', 'been',
|
|
72
|
+
'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would',
|
|
73
|
+
'could', 'should', 'may', 'might', 'shall', 'can', 'meeting', 'call',
|
|
74
|
+
'sync', 'chat', 'discussion', 're', 'fwd', 'fw',
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
const words = subject
|
|
78
|
+
.toLowerCase()
|
|
79
|
+
.replace(/[^a-z0-9\s]/g, '')
|
|
80
|
+
.split(/\s+/)
|
|
81
|
+
.filter((w) => w.length > 2 && !stopWords.has(w));
|
|
82
|
+
|
|
83
|
+
const unique = [...new Set(words)];
|
|
84
|
+
return unique.map((w) => `Discuss ${w}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
package/src/optimizer.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { AnalyzerConfig, OptimizationSuggestion, ScheduleAnalysis } from './types.js';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_BUFFER_MINUTES = 5;
|
|
4
|
+
|
|
5
|
+
export class ScheduleOptimizer {
|
|
6
|
+
private bufferMinutes: number;
|
|
7
|
+
|
|
8
|
+
constructor(config?: AnalyzerConfig) {
|
|
9
|
+
this.bufferMinutes = config?.bufferBetweenMeetingsMinutes ?? DEFAULT_BUFFER_MINUTES;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
suggest(analysis: ScheduleAnalysis): OptimizationSuggestion[] {
|
|
13
|
+
const suggestions: OptimizationSuggestion[] = [];
|
|
14
|
+
|
|
15
|
+
if (analysis.meetingLoadHours > 6) {
|
|
16
|
+
suggestions.push({
|
|
17
|
+
type: 'decline',
|
|
18
|
+
description: 'Meeting load exceeds 6 hours. Consider declining optional meetings to protect your time.',
|
|
19
|
+
priority: 'high',
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (analysis.meetingLoadHours > 4 && analysis.focusBlocks.length === 0) {
|
|
24
|
+
suggestions.push({
|
|
25
|
+
type: 'add-focus-block',
|
|
26
|
+
description: 'Over 4 hours of meetings with no focus blocks. Block time for deep work.',
|
|
27
|
+
priority: 'high',
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
for (const conflict of analysis.conflicts) {
|
|
32
|
+
suggestions.push({
|
|
33
|
+
type: 'reschedule',
|
|
34
|
+
description: `Conflict: "${conflict.event1Subject}" overlaps with "${conflict.event2Subject}" by ${conflict.overlapMinutes} minutes.`,
|
|
35
|
+
eventId: conflict.event2Id,
|
|
36
|
+
priority: 'high',
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Back-to-back detection
|
|
41
|
+
const sorted = [...analysis.events].sort((a, b) => a.start.localeCompare(b.start));
|
|
42
|
+
for (let i = 0; i < sorted.length - 1; i++) {
|
|
43
|
+
const currentEnd = new Date(sorted[i].end).getTime();
|
|
44
|
+
const nextStart = new Date(sorted[i + 1].start).getTime();
|
|
45
|
+
const gapMinutes = (nextStart - currentEnd) / (1000 * 60);
|
|
46
|
+
if (gapMinutes >= 0 && gapMinutes < this.bufferMinutes) {
|
|
47
|
+
suggestions.push({
|
|
48
|
+
type: 'add-buffer',
|
|
49
|
+
description: `Only ${gapMinutes} minutes between "${sorted[i].subject}" and "${sorted[i + 1].subject}". Add a buffer.`,
|
|
50
|
+
eventId: sorted[i + 1].id,
|
|
51
|
+
priority: 'medium',
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (const event of analysis.events) {
|
|
57
|
+
const durationMs = new Date(event.end).getTime() - new Date(event.start).getTime();
|
|
58
|
+
const durationMinutes = durationMs / (1000 * 60);
|
|
59
|
+
if (durationMinutes > 60) {
|
|
60
|
+
suggestions.push({
|
|
61
|
+
type: 'shorten',
|
|
62
|
+
description: `"${event.subject}" is ${durationMinutes} minutes. Consider shortening to 45-60 minutes.`,
|
|
63
|
+
eventId: event.id,
|
|
64
|
+
priority: 'low',
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return suggestions;
|
|
70
|
+
}
|
|
71
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export interface CalendarEvent {
|
|
2
|
+
id: string;
|
|
3
|
+
subject: string;
|
|
4
|
+
start: string; // ISO 8601
|
|
5
|
+
end: string; // ISO 8601
|
|
6
|
+
attendees: Attendee[];
|
|
7
|
+
location?: string;
|
|
8
|
+
isOnlineMeeting: boolean;
|
|
9
|
+
organizer?: string;
|
|
10
|
+
body?: string;
|
|
11
|
+
isAllDay?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface Attendee {
|
|
15
|
+
email: string;
|
|
16
|
+
name?: string;
|
|
17
|
+
responseStatus: 'accepted' | 'tentative' | 'declined' | 'none';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface TimeSlot {
|
|
21
|
+
start: string; // ISO 8601
|
|
22
|
+
end: string; // ISO 8601
|
|
23
|
+
durationMinutes: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ConflictInfo {
|
|
27
|
+
event1Id: string;
|
|
28
|
+
event2Id: string;
|
|
29
|
+
event1Subject: string;
|
|
30
|
+
event2Subject: string;
|
|
31
|
+
overlapStart: string;
|
|
32
|
+
overlapEnd: string;
|
|
33
|
+
overlapMinutes: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ScheduleAnalysis {
|
|
37
|
+
date: string;
|
|
38
|
+
events: CalendarEvent[];
|
|
39
|
+
freeSlots: TimeSlot[];
|
|
40
|
+
conflicts: ConflictInfo[];
|
|
41
|
+
focusBlocks: TimeSlot[];
|
|
42
|
+
meetingLoadHours: number;
|
|
43
|
+
eventCount: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface OptimizationSuggestion {
|
|
47
|
+
type: 'add-focus-block' | 'reschedule' | 'decline' | 'shorten' | 'add-buffer';
|
|
48
|
+
description: string;
|
|
49
|
+
eventId?: string;
|
|
50
|
+
priority: 'high' | 'medium' | 'low';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface MeetingBrief {
|
|
54
|
+
eventId: string;
|
|
55
|
+
subject: string;
|
|
56
|
+
startTime: string;
|
|
57
|
+
attendees: AttendeeContext[];
|
|
58
|
+
agenda?: string;
|
|
59
|
+
suggestedTopics: string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface AttendeeContext {
|
|
63
|
+
email: string;
|
|
64
|
+
name?: string;
|
|
65
|
+
lastInteraction?: string;
|
|
66
|
+
relationship?: string;
|
|
67
|
+
notes?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface AnalyzerConfig {
|
|
71
|
+
workdayStartHour?: number; // default 9
|
|
72
|
+
workdayEndHour?: number; // default 17
|
|
73
|
+
focusBlockMinMinutes?: number; // default 60
|
|
74
|
+
bufferBetweenMeetingsMinutes?: number; // default 5
|
|
75
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ScheduleAnalyzer } from '../src/analyzer.js';
|
|
3
|
+
import type { CalendarEvent } from '../src/types.js';
|
|
4
|
+
|
|
5
|
+
function makeEvent(overrides: Partial<CalendarEvent> & { id: string; start: string; end: string }): CalendarEvent {
|
|
6
|
+
return {
|
|
7
|
+
subject: 'Test Meeting',
|
|
8
|
+
attendees: [],
|
|
9
|
+
isOnlineMeeting: false,
|
|
10
|
+
...overrides,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('ScheduleAnalyzer', () => {
|
|
15
|
+
const analyzer = new ScheduleAnalyzer();
|
|
16
|
+
|
|
17
|
+
describe('analyzeDay', () => {
|
|
18
|
+
it('counts events correctly', () => {
|
|
19
|
+
const events = [
|
|
20
|
+
makeEvent({ id: '1', start: '2025-03-15T09:00:00Z', end: '2025-03-15T10:00:00Z' }),
|
|
21
|
+
makeEvent({ id: '2', start: '2025-03-15T11:00:00Z', end: '2025-03-15T12:00:00Z' }),
|
|
22
|
+
makeEvent({ id: '3', start: '2025-03-16T09:00:00Z', end: '2025-03-16T10:00:00Z' }),
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const result = analyzer.analyzeDay(events, '2025-03-15');
|
|
26
|
+
expect(result.eventCount).toBe(2);
|
|
27
|
+
expect(result.events).toHaveLength(2);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('finds free slots between events', () => {
|
|
31
|
+
const events = [
|
|
32
|
+
makeEvent({ id: '1', start: '2025-03-15T09:00:00Z', end: '2025-03-15T10:00:00Z' }),
|
|
33
|
+
makeEvent({ id: '2', start: '2025-03-15T14:00:00Z', end: '2025-03-15T15:00:00Z' }),
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const result = analyzer.analyzeDay(events, '2025-03-15');
|
|
37
|
+
expect(result.freeSlots.length).toBeGreaterThanOrEqual(1);
|
|
38
|
+
// There should be a free slot from 10:00 to 14:00
|
|
39
|
+
const bigSlot = result.freeSlots.find((s) => s.durationMinutes === 240);
|
|
40
|
+
expect(bigSlot).toBeDefined();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('respects workday bounds', () => {
|
|
44
|
+
const customAnalyzer = new ScheduleAnalyzer({ workdayStartHour: 8, workdayEndHour: 18 });
|
|
45
|
+
const events = [
|
|
46
|
+
makeEvent({ id: '1', start: '2025-03-15T10:00:00Z', end: '2025-03-15T11:00:00Z' }),
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
const result = customAnalyzer.analyzeDay(events, '2025-03-15');
|
|
50
|
+
// Free slot before: 08:00-10:00 (120 min), after: 11:00-18:00 (420 min)
|
|
51
|
+
const beforeSlot = result.freeSlots.find((s) => s.durationMinutes === 120);
|
|
52
|
+
const afterSlot = result.freeSlots.find((s) => s.durationMinutes === 420);
|
|
53
|
+
expect(beforeSlot).toBeDefined();
|
|
54
|
+
expect(afterSlot).toBeDefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('detects overlapping events as conflicts', () => {
|
|
58
|
+
const events = [
|
|
59
|
+
makeEvent({ id: '1', subject: 'Meeting A', start: '2025-03-15T09:00:00Z', end: '2025-03-15T10:30:00Z' }),
|
|
60
|
+
makeEvent({ id: '2', subject: 'Meeting B', start: '2025-03-15T10:00:00Z', end: '2025-03-15T11:00:00Z' }),
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
const result = analyzer.analyzeDay(events, '2025-03-15');
|
|
64
|
+
expect(result.conflicts).toHaveLength(1);
|
|
65
|
+
expect(result.conflicts[0].overlapMinutes).toBe(30);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('identifies focus blocks (slots >= 60min)', () => {
|
|
69
|
+
const events = [
|
|
70
|
+
makeEvent({ id: '1', start: '2025-03-15T09:00:00Z', end: '2025-03-15T09:30:00Z' }),
|
|
71
|
+
makeEvent({ id: '2', start: '2025-03-15T09:45:00Z', end: '2025-03-15T10:00:00Z' }),
|
|
72
|
+
makeEvent({ id: '3', start: '2025-03-15T14:00:00Z', end: '2025-03-15T15:00:00Z' }),
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
const result = analyzer.analyzeDay(events, '2025-03-15');
|
|
76
|
+
// 09:30-09:45 = 15min (not focus), 10:00-14:00 = 240min (focus), 15:00-17:00 = 120min (focus)
|
|
77
|
+
expect(result.focusBlocks.length).toBe(2);
|
|
78
|
+
expect(result.focusBlocks.every((b) => b.durationMinutes >= 60)).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('calculates meeting load hours', () => {
|
|
82
|
+
const events = [
|
|
83
|
+
makeEvent({ id: '1', start: '2025-03-15T09:00:00Z', end: '2025-03-15T10:00:00Z' }),
|
|
84
|
+
makeEvent({ id: '2', start: '2025-03-15T11:00:00Z', end: '2025-03-15T12:30:00Z' }),
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
const result = analyzer.analyzeDay(events, '2025-03-15');
|
|
88
|
+
expect(result.meetingLoadHours).toBe(2.5);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('findFreeSlots', () => {
|
|
93
|
+
it('finds gaps correctly', () => {
|
|
94
|
+
const events = [
|
|
95
|
+
makeEvent({ id: '1', start: '2025-03-15T09:00:00Z', end: '2025-03-15T10:00:00Z' }),
|
|
96
|
+
makeEvent({ id: '2', start: '2025-03-15T12:00:00Z', end: '2025-03-15T13:00:00Z' }),
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
const slots = analyzer.findFreeSlots(events, '2025-03-15T08:00:00Z', '2025-03-15T17:00:00Z');
|
|
100
|
+
expect(slots).toHaveLength(3);
|
|
101
|
+
// 08-09 (60min), 10-12 (120min), 13-17 (240min)
|
|
102
|
+
expect(slots[0].durationMinutes).toBe(60);
|
|
103
|
+
expect(slots[1].durationMinutes).toBe(120);
|
|
104
|
+
expect(slots[2].durationMinutes).toBe(240);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('filters by minimum duration', () => {
|
|
108
|
+
const events = [
|
|
109
|
+
makeEvent({ id: '1', start: '2025-03-15T09:00:00Z', end: '2025-03-15T10:00:00Z' }),
|
|
110
|
+
makeEvent({ id: '2', start: '2025-03-15T10:15:00Z', end: '2025-03-15T11:00:00Z' }),
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
const slots = analyzer.findFreeSlots(events, '2025-03-15T09:00:00Z', '2025-03-15T12:00:00Z', 30);
|
|
114
|
+
// 10:00-10:15 = 15min (excluded), 11:00-12:00 = 60min (included)
|
|
115
|
+
expect(slots).toHaveLength(1);
|
|
116
|
+
expect(slots[0].durationMinutes).toBe(60);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('handles empty event list (whole range is free)', () => {
|
|
120
|
+
const slots = analyzer.findFreeSlots([], '2025-03-15T09:00:00Z', '2025-03-15T17:00:00Z');
|
|
121
|
+
expect(slots).toHaveLength(1);
|
|
122
|
+
expect(slots[0].durationMinutes).toBe(480);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('detectConflicts', () => {
|
|
127
|
+
it('returns empty for non-overlapping events', () => {
|
|
128
|
+
const events = [
|
|
129
|
+
makeEvent({ id: '1', start: '2025-03-15T09:00:00Z', end: '2025-03-15T10:00:00Z' }),
|
|
130
|
+
makeEvent({ id: '2', start: '2025-03-15T10:00:00Z', end: '2025-03-15T11:00:00Z' }),
|
|
131
|
+
makeEvent({ id: '3', start: '2025-03-15T14:00:00Z', end: '2025-03-15T15:00:00Z' }),
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
const conflicts = analyzer.detectConflicts(events);
|
|
135
|
+
expect(conflicts).toHaveLength(0);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('detects multiple conflicts', () => {
|
|
139
|
+
const events = [
|
|
140
|
+
makeEvent({ id: '1', subject: 'A', start: '2025-03-15T09:00:00Z', end: '2025-03-15T10:30:00Z' }),
|
|
141
|
+
makeEvent({ id: '2', subject: 'B', start: '2025-03-15T10:00:00Z', end: '2025-03-15T11:00:00Z' }),
|
|
142
|
+
makeEvent({ id: '3', subject: 'C', start: '2025-03-15T10:15:00Z', end: '2025-03-15T11:30:00Z' }),
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
const conflicts = analyzer.detectConflicts(events);
|
|
146
|
+
// A overlaps B, A overlaps C, B overlaps C
|
|
147
|
+
expect(conflicts).toHaveLength(3);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { MeetingPrepGenerator } from '../src/meeting-prep.js';
|
|
3
|
+
import type { AttendeeContext, CalendarEvent } from '../src/types.js';
|
|
4
|
+
|
|
5
|
+
function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
|
|
6
|
+
return {
|
|
7
|
+
id: 'evt-1',
|
|
8
|
+
subject: 'Project Review',
|
|
9
|
+
start: '2025-03-15T10:00:00Z',
|
|
10
|
+
end: '2025-03-15T11:00:00Z',
|
|
11
|
+
attendees: [
|
|
12
|
+
{ email: 'alice@example.com', name: 'Alice', responseStatus: 'accepted' },
|
|
13
|
+
{ email: 'bob@example.com', name: 'Bob', responseStatus: 'tentative' },
|
|
14
|
+
],
|
|
15
|
+
isOnlineMeeting: true,
|
|
16
|
+
...overrides,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('MeetingPrepGenerator', () => {
|
|
21
|
+
const generator = new MeetingPrepGenerator();
|
|
22
|
+
|
|
23
|
+
describe('generateBrief', () => {
|
|
24
|
+
it('extracts attendees', () => {
|
|
25
|
+
const event = makeEvent();
|
|
26
|
+
const brief = generator.generateBrief(event);
|
|
27
|
+
expect(brief.attendees).toHaveLength(2);
|
|
28
|
+
expect(brief.attendees[0].email).toBe('alice@example.com');
|
|
29
|
+
expect(brief.attendees[1].email).toBe('bob@example.com');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('extracts agenda from body', () => {
|
|
33
|
+
const event = makeEvent({
|
|
34
|
+
body: 'Hello team,\n\nAgenda:\n- Review Q1 results\n- Discuss roadmap\n- Action items\n\nThanks',
|
|
35
|
+
});
|
|
36
|
+
const brief = generator.generateBrief(event);
|
|
37
|
+
expect(brief.agenda).toBeDefined();
|
|
38
|
+
expect(brief.agenda).toContain('Review Q1 results');
|
|
39
|
+
expect(brief.agenda).toContain('Discuss roadmap');
|
|
40
|
+
expect(brief.agenda).toContain('Action items');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('generates suggested topics from subject', () => {
|
|
44
|
+
const event = makeEvent({ subject: 'Q1 Budget Planning Review' });
|
|
45
|
+
const brief = generator.generateBrief(event);
|
|
46
|
+
expect(brief.suggestedTopics.length).toBeGreaterThan(0);
|
|
47
|
+
expect(brief.suggestedTopics.some((t) => t.toLowerCase().includes('budget'))).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('merges attendee context when provided', () => {
|
|
51
|
+
const event = makeEvent();
|
|
52
|
+
const contexts: AttendeeContext[] = [
|
|
53
|
+
{ email: 'alice@example.com', relationship: 'manager', notes: 'Decision maker' },
|
|
54
|
+
];
|
|
55
|
+
const brief = generator.generateBrief(event, contexts);
|
|
56
|
+
const alice = brief.attendees.find((a) => a.email === 'alice@example.com');
|
|
57
|
+
expect(alice?.relationship).toBe('manager');
|
|
58
|
+
expect(alice?.notes).toBe('Decision maker');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('getUpcoming', () => {
|
|
63
|
+
it('returns events within window', () => {
|
|
64
|
+
const now = new Date('2025-03-15T09:00:00Z');
|
|
65
|
+
const events = [
|
|
66
|
+
makeEvent({ id: '1', start: '2025-03-15T09:15:00Z', end: '2025-03-15T10:00:00Z' }),
|
|
67
|
+
makeEvent({ id: '2', start: '2025-03-15T09:45:00Z', end: '2025-03-15T10:30:00Z' }),
|
|
68
|
+
makeEvent({ id: '3', start: '2025-03-15T12:00:00Z', end: '2025-03-15T13:00:00Z' }),
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
const upcoming = generator.getUpcoming(events, 60, now);
|
|
72
|
+
expect(upcoming).toHaveLength(2);
|
|
73
|
+
expect(upcoming.map((e) => e.id)).toEqual(['1', '2']);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('excludes past events', () => {
|
|
77
|
+
const now = new Date('2025-03-15T10:30:00Z');
|
|
78
|
+
const events = [
|
|
79
|
+
makeEvent({ id: '1', start: '2025-03-15T09:00:00Z', end: '2025-03-15T10:00:00Z' }),
|
|
80
|
+
makeEvent({ id: '2', start: '2025-03-15T10:00:00Z', end: '2025-03-15T11:00:00Z' }),
|
|
81
|
+
makeEvent({ id: '3', start: '2025-03-15T11:00:00Z', end: '2025-03-15T12:00:00Z' }),
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
const upcoming = generator.getUpcoming(events, 60, now);
|
|
85
|
+
// Event 1 started in the past, Event 2 already started, only Event 3 is upcoming
|
|
86
|
+
expect(upcoming).toHaveLength(1);
|
|
87
|
+
expect(upcoming[0].id).toBe('3');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ScheduleOptimizer } from '../src/optimizer.js';
|
|
3
|
+
import type { ScheduleAnalysis, CalendarEvent, ConflictInfo, TimeSlot } from '../src/types.js';
|
|
4
|
+
|
|
5
|
+
function makeAnalysis(overrides: Partial<ScheduleAnalysis> = {}): ScheduleAnalysis {
|
|
6
|
+
return {
|
|
7
|
+
date: '2025-03-15',
|
|
8
|
+
events: [],
|
|
9
|
+
freeSlots: [],
|
|
10
|
+
conflicts: [],
|
|
11
|
+
focusBlocks: [],
|
|
12
|
+
meetingLoadHours: 0,
|
|
13
|
+
eventCount: 0,
|
|
14
|
+
...overrides,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makeEvent(id: string, start: string, end: string, subject = 'Meeting'): CalendarEvent {
|
|
19
|
+
return { id, subject, start, end, attendees: [], isOnlineMeeting: false };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('ScheduleOptimizer', () => {
|
|
23
|
+
const optimizer = new ScheduleOptimizer();
|
|
24
|
+
|
|
25
|
+
it('suggests decline when meeting load > 6 hours', () => {
|
|
26
|
+
const analysis = makeAnalysis({ meetingLoadHours: 7 });
|
|
27
|
+
const suggestions = optimizer.suggest(analysis);
|
|
28
|
+
expect(suggestions.some((s) => s.type === 'decline' && s.priority === 'high')).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('suggests add-focus-block when no focus blocks and load > 4', () => {
|
|
32
|
+
const analysis = makeAnalysis({ meetingLoadHours: 5, focusBlocks: [] });
|
|
33
|
+
const suggestions = optimizer.suggest(analysis);
|
|
34
|
+
expect(suggestions.some((s) => s.type === 'add-focus-block' && s.priority === 'high')).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('suggests reschedule for conflicts', () => {
|
|
38
|
+
const conflict: ConflictInfo = {
|
|
39
|
+
event1Id: '1',
|
|
40
|
+
event2Id: '2',
|
|
41
|
+
event1Subject: 'A',
|
|
42
|
+
event2Subject: 'B',
|
|
43
|
+
overlapStart: '2025-03-15T10:00:00Z',
|
|
44
|
+
overlapEnd: '2025-03-15T10:30:00Z',
|
|
45
|
+
overlapMinutes: 30,
|
|
46
|
+
};
|
|
47
|
+
const analysis = makeAnalysis({ conflicts: [conflict] });
|
|
48
|
+
const suggestions = optimizer.suggest(analysis);
|
|
49
|
+
expect(suggestions.some((s) => s.type === 'reschedule' && s.priority === 'high')).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('suggests buffer for back-to-back meetings', () => {
|
|
53
|
+
const events = [
|
|
54
|
+
makeEvent('1', '2025-03-15T09:00:00Z', '2025-03-15T10:00:00Z', 'First'),
|
|
55
|
+
makeEvent('2', '2025-03-15T10:02:00Z', '2025-03-15T11:00:00Z', 'Second'),
|
|
56
|
+
];
|
|
57
|
+
const analysis = makeAnalysis({ events });
|
|
58
|
+
const suggestions = optimizer.suggest(analysis);
|
|
59
|
+
expect(suggestions.some((s) => s.type === 'add-buffer' && s.priority === 'medium')).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('returns empty for light schedule', () => {
|
|
63
|
+
const events = [
|
|
64
|
+
makeEvent('1', '2025-03-15T10:00:00Z', '2025-03-15T10:30:00Z'),
|
|
65
|
+
];
|
|
66
|
+
const focusBlock: TimeSlot = {
|
|
67
|
+
start: '2025-03-15T10:30:00Z',
|
|
68
|
+
end: '2025-03-15T17:00:00Z',
|
|
69
|
+
durationMinutes: 390,
|
|
70
|
+
};
|
|
71
|
+
const analysis = makeAnalysis({
|
|
72
|
+
events,
|
|
73
|
+
meetingLoadHours: 0.5,
|
|
74
|
+
focusBlocks: [focusBlock],
|
|
75
|
+
});
|
|
76
|
+
const suggestions = optimizer.suggest(analysis);
|
|
77
|
+
expect(suggestions).toHaveLength(0);
|
|
78
|
+
});
|
|
79
|
+
});
|