@luckydye/calendar 1.3.2 → 1.4.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.
@@ -1,50 +1,53 @@
1
+ import type {
2
+ CalendarCredentials,
3
+ CalendarSource,
4
+ } from "./CalendarIntegration.js";
1
5
  import type { CalendarEvent } from "./CalendarInternal.js";
2
- import type { CalendarSource, CalendarCredentials } from "./CalendarIntegration.js";
3
6
 
4
7
  /**
5
8
  * Inhouse Booking System credentials.
6
9
  */
7
10
  export interface InhouseCredentials extends CalendarCredentials {
8
- /**
9
- * Session cookie for authentication (e.g., "XSRF-TOKEN=xxx; laravel_session=yyy")
10
- */
11
- sessionCookie: string;
12
- /**
13
- * Employee ID to filter bookings for
14
- */
15
- employeeId: string;
16
- /**
17
- * Unit ID (optional, for filtering by unit/location)
18
- */
19
- unitId?: string;
20
- /**
21
- * Hour at which bookings start (9 or 10, defaults to 9)
22
- */
23
- startHour?: 9 | 10;
11
+ /**
12
+ * Session cookie for authentication (e.g., "XSRF-TOKEN=xxx; laravel_session=yyy")
13
+ */
14
+ sessionCookie: string;
15
+ /**
16
+ * Employee ID to filter bookings for
17
+ */
18
+ employeeId: string;
19
+ /**
20
+ * Unit ID (optional, for filtering by unit/location)
21
+ */
22
+ unitId?: string;
23
+ /**
24
+ * Hour at which bookings start (9 or 10, defaults to 9)
25
+ */
26
+ startHour?: 9 | 10;
24
27
  }
25
28
 
26
29
  /**
27
30
  * Inhouse Booking System API response structure.
28
31
  */
29
32
  interface InhouseBooking {
30
- id: number;
31
- date: string;
32
- description: string;
33
- duration: string;
34
- project_id: number;
35
- employee_id: number;
36
- booking_id: number;
37
- closing_id: number | null;
38
- project: Array<{
39
- id: number;
40
- name: string;
41
- report: number;
42
- }>;
43
- account: Array<{
44
- id: number;
45
- name: string;
46
- }>;
47
- optional?: number;
33
+ id: number;
34
+ date: string;
35
+ description: string;
36
+ duration: string;
37
+ project_id: number;
38
+ employee_id: number;
39
+ booking_id: number;
40
+ closing_id: number | null;
41
+ project: Array<{
42
+ id: number;
43
+ name: string;
44
+ report: number;
45
+ }>;
46
+ account: Array<{
47
+ id: number;
48
+ name: string;
49
+ }>;
50
+ optional?: number;
48
51
  }
49
52
 
50
53
  /**
@@ -65,492 +68,580 @@ interface InhouseBooking {
65
68
  * const events = await source.fetchEvents();
66
69
  */
67
70
  export class InhouseBookingSource implements CalendarSource {
68
- readonly type = 'inhouse';
69
- credentials: InhouseCredentials;
70
- enabled: boolean;
71
-
72
- constructor(
73
- public id: string,
74
- public name: string,
75
- public color: string,
76
- credentials: InhouseCredentials,
77
- enabled = true
78
- ) {
79
- this.credentials = credentials;
80
- this.enabled = enabled;
81
- }
82
-
83
- /**
84
- * Make an authenticated request to the Inhouse Booking API via the proxy.
85
- * The proxy will inject the session cookie.
86
- */
87
- private async apiRequest<T>(endpoint: string, searchParams?: URLSearchParams): Promise<T> {
88
- const url = searchParams ? `${endpoint}?${searchParams.toString()}` : endpoint;
89
-
90
- const response = await fetch(url, {
91
- headers: {
92
- 'X-Session-Cookie': this.credentials.sessionCookie,
93
- },
94
- });
95
-
96
- if (!response.ok) {
97
- if (response.status === 401) {
98
- throw new Error('Inhouse Booking System authentication failed. Please check your session cookie.');
99
- }
100
- throw new Error(`Inhouse Booking API error: ${response.status} ${response.statusText}`);
101
- }
102
-
103
- return response.json() as Promise<T>;
104
- }
105
-
106
- /**
107
- * Convert an Inhouse booking to internal CalendarEvent format.
108
- * Bookings use date + duration (format "HH:MM").
109
- * Bookings are ordered by their position in the day (first booking = starts at 09:00).
110
- */
111
- private mapBookingToEvent(
112
- booking: InhouseBooking,
113
- startTime: Date
114
- ): CalendarEvent | null {
115
- if (!booking.date || !booking.duration) return null;
116
-
117
- // Parse duration string "HH:MM" to hours
118
- const [hours, minutes] = booking.duration.split(':').map(Number);
119
- if (hours === undefined || minutes === undefined) return null;
120
-
121
- const durationMs = (hours * 60 + minutes) * 60 * 1000;
122
- const endTime = new Date(startTime.getTime() + durationMs);
123
-
124
- if (Number.isNaN(startTime.getTime()) || Number.isNaN(endTime.getTime())) return null;
125
-
126
- const projectName = booking.project[0]?.name || '';
127
- const eventId = `${booking.id}:${booking.booking_id}:${booking.project_id}`;
128
-
129
- // Events with id <= 0 are raw bookings (no timetrack record yet); render as TENTATIVE.
130
- const isRawBooking = booking.id <= 0;
131
-
132
- return {
133
- id: eventId,
134
- title: projectName,
135
- start: startTime,
136
- end: endTime,
137
- color: this.color,
138
- calendar: this.name,
139
- calendarId: this.id,
140
- sourceId: this.id,
141
- description: booking.description || '',
142
- readOnly: false,
143
- status: (booking.optional === 1 || isRawBooking) ? 'TENTATIVE' : undefined,
144
- };
145
- }
146
-
147
- /**
148
- * Process bookings and calculate their timestamps based on order.
149
- * Bookings are stacked chronologically within a 9-hour workday starting at 09:00,
150
- * split into a morning and afternoon session by a 1-hour lunch break.
151
- *
152
- * The split is determined by the midpoint of total work duration:
153
- * bookings are greedily assigned to the morning until adding the next one
154
- * would exceed half the day's total duration. The remaining bookings are
155
- * placed after the 1-hour lunch break.
156
- *
157
- * Example: 3.5h + 4.5h = 8h total → lunch after first (3.5h ≤ 4h),
158
- * result: 09:00-12:30, lunch 12:30-13:30, 13:30-18:00.
159
- */
160
- private processBookings(bookings: InhouseBooking[]): CalendarEvent[] {
161
- const bookingsByDate = new Map<string, InhouseBooking[]>();
162
-
163
- for (const booking of bookings) {
164
- if (!booking.date) continue;
165
- const list = bookingsByDate.get(booking.date) || [];
166
- list.push(booking);
167
- bookingsByDate.set(booking.date, list);
168
- }
169
-
170
- const events: CalendarEvent[] = [];
171
-
172
- for (const [dateStr, dateBookings] of bookingsByDate) {
173
- const [year, month, day] = dateStr.split('-').map(Number);
174
- if (!year || !month || !day) continue;
175
-
176
- const durations = dateBookings.map(b => {
177
- if (!b.duration) return 0;
178
- const [h, m] = b.duration.split(':').map(Number);
179
- if (h === undefined || m === undefined) return 0;
180
- return (h * 60 + m) * 60 * 1000;
181
- });
182
-
183
- const halfMs = durations.reduce((a, b) => a + b, 0) / 2;
184
-
185
- // Greedy split: assign bookings to morning until the next one would exceed half
186
- let morningMs = 0;
187
- let splitIndex = 0;
188
- for (let i = 0; i < dateBookings.length; i++) {
189
- if (morningMs + (durations[i] ?? 0) <= halfMs) {
190
- morningMs += durations[i] ?? 0;
191
- splitIndex = i + 1;
192
- } else {
193
- break;
194
- }
195
- }
196
-
197
- const LUNCH_MS = 60 * 60 * 1000;
198
- let currentTime = new Date(year, month - 1, day, this.credentials.startHour ?? 9, 0);
199
-
200
- for (let i = 0; i < dateBookings.length; i++) {
201
- // Insert 1-hour lunch break between morning and afternoon sessions
202
- if (i === splitIndex && splitIndex > 0) {
203
- currentTime = new Date(currentTime.getTime() + LUNCH_MS);
204
- }
205
-
206
- const event = this.mapBookingToEvent(dateBookings[i] as InhouseBooking, currentTime);
207
- if (event) {
208
- events.push(event);
209
- currentTime = event.end;
210
- }
211
- }
212
- }
213
-
214
- return events;
215
- }
216
-
217
- /**
218
- * Fetch bookings from the Inhouse Booking System.
219
- * Fetches bookings from 1 year ago to 1 year in the future.
220
- */
221
- async fetchEvents(): Promise<CalendarEvent[]> {
222
- if (!this.enabled) return [];
223
-
224
- const now = new Date();
225
- const oneYearAgo = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000);
226
- const oneYearFuture = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000);
227
-
228
- const formatDate = (date: Date): string => date.toISOString().split('T')[0];
229
-
230
- const dateFilter = {
231
- '>=': formatDate(oneYearAgo),
232
- '<=': formatDate(oneYearFuture),
233
- };
234
-
235
- const employeeFilter = {
236
- '=': Number.parseInt(this.credentials.employeeId, 10),
237
- };
238
-
239
- const params = new URLSearchParams({
240
- date: JSON.stringify(dateFilter),
241
- employee_id: JSON.stringify(employeeFilter),
242
- });
243
-
244
- const bookings = await this.apiRequest<InhouseBooking[]>('/timetracks/with_bookings', params);
245
-
246
- if (!Array.isArray(bookings)) return [];
247
-
248
- return this.processBookings(bookings);
249
- }
250
-
251
- /**
252
- * Fetch the list of active projects.
253
- * Returns projects as {id, name} pairs for use in the project picker.
254
- */
255
- async fetchProjects(): Promise<Array<{ id: number; name: string }>> {
256
- const params = new URLSearchParams({
257
- archived: JSON.stringify({ '=': 0 }),
258
- });
259
- const projects = await this.apiRequest<Array<{ id: number; name: string }>>('/projects', params);
260
- if (!Array.isArray(projects)) return [];
261
- return projects;
262
- }
263
-
264
- /**
265
- * Extract XSRF-TOKEN value from session cookie.
266
- * Returns the encrypted token value that Laravel can decrypt.
267
- */
268
- private getXsrfToken(): string | undefined {
269
- const match = this.credentials.sessionCookie.match(/XSRF-TOKEN=([^;]+)/);
270
- return match ? decodeURIComponent(match[1]) : undefined;
271
- }
272
-
273
- /**
274
- * Make an authenticated write request to the Inhouse Booking API via the proxy.
275
- * Uses X-XSRF-TOKEN header with the encrypted token (Laravel decrypts this automatically).
276
- */
277
- private async apiWriteRequest<T>(endpoint: string, method: string, body: URLSearchParams): Promise<T> {
278
- const xsrfToken = this.getXsrfToken();
279
-
280
- if (!xsrfToken) {
281
- throw new Error('XSRF-TOKEN not found in session cookie. Please check your credentials.');
282
- }
283
-
284
- const headers: Record<string, string> = {
285
- 'X-Session-Cookie': this.credentials.sessionCookie,
286
- 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
287
- 'X-Requested-With': 'XMLHttpRequest',
288
- 'X-XSRF-TOKEN': xsrfToken,
289
- };
290
-
291
- const response = await fetch(endpoint, {
292
- method,
293
- headers,
294
- body: body.toString(),
295
- });
296
-
297
- if (!response.ok) {
298
- if (response.status === 401) {
299
- throw new Error('Inhouse Booking System authentication failed. Please check your session cookie.');
300
- }
301
- if (response.status === 403) {
302
- throw new Error('Inhouse Booking System CSRF token invalid or missing.');
303
- }
304
- throw new Error(`Inhouse Booking API error: ${response.status} ${response.statusText}`);
305
- }
306
-
307
- const text = await response.text();
308
- if (!text) return {} as T;
309
-
310
- try {
311
- return JSON.parse(text) as T;
312
- } catch {
313
- return {} as T;
314
- }
315
- }
316
-
317
- /**
318
- * Make an authenticated write request with params as query string and empty body.
319
- * This matches the Inhouse API format used by the browser client for creating bookings.
320
- */
321
- private async apiQueryRequest<T>(endpoint: string, method: string, params: URLSearchParams): Promise<T> {
322
- const xsrfToken = this.getXsrfToken();
323
-
324
- if (!xsrfToken) {
325
- throw new Error('XSRF-TOKEN not found in session cookie. Please check your credentials.');
326
- }
327
-
328
- const url = `${endpoint}/?${params.toString()}`;
329
-
330
- const response = await fetch(url, {
331
- method,
332
- headers: {
333
- 'X-Session-Cookie': this.credentials.sessionCookie,
334
- 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
335
- 'X-Requested-With': 'XMLHttpRequest',
336
- 'X-XSRF-TOKEN': xsrfToken,
337
- },
338
- });
339
-
340
- if (!response.ok) {
341
- if (response.status === 401) {
342
- throw new Error('Inhouse Booking System authentication failed. Please check your session cookie.');
343
- }
344
- if (response.status === 403) {
345
- throw new Error('Inhouse Booking System CSRF token invalid or missing.');
346
- }
347
- throw new Error(`Inhouse Booking API error: ${response.status} ${response.statusText}`);
348
- }
349
-
350
- const text = await response.text();
351
- if (!text) return {} as T;
352
-
353
- try {
354
- return JSON.parse(text) as T;
355
- } catch {
356
- return {} as T;
357
- }
358
- }
359
-
360
- /**
361
- * Format duration from milliseconds to "HH:MM" format.
362
- */
363
- private formatDuration(durationMs: number): string {
364
- const totalMinutes = Math.round(durationMs / (60 * 1000));
365
- const hours = Math.floor(totalMinutes / 60);
366
- const minutes = totalMinutes % 60;
367
- return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
368
- }
369
-
370
- /**
371
- * Parse event ID in format "{id}:{booking_id}:{project_id}".
372
- * The API resource ID is bookingId when id is unavailable (< 0).
373
- */
374
- private parseEventId(eventId: string): { id: number; bookingId: number; projectId: number; resourceId: number } | null {
375
- const parts = eventId.split(':');
376
- if (parts.length !== 3) return null;
377
- const id = Number.parseInt(parts[0], 10);
378
- const bookingId = Number.parseInt(parts[1], 10);
379
- const projectId = Number.parseInt(parts[2], 10);
380
- if (Number.isNaN(id) || Number.isNaN(bookingId) || Number.isNaN(projectId)) return null;
381
- const resourceId = id > 0 ? id : bookingId;
382
- return { id, bookingId, projectId, resourceId };
383
- }
384
-
385
- /**
386
- * Create a new booking in the Inhouse Booking System.
387
- * Params are sent as query string with empty body, matching the Inhouse API format.
388
- */
389
- async createEvent(event: Omit<CalendarEvent, 'calendar' | 'color'>): Promise<CalendarEvent> {
390
- if (!this.enabled) {
391
- throw new Error('Inhouse Booking System source is disabled');
392
- }
393
-
394
- const parsed = this.parseEventId(event.id);
395
- if (!parsed) {
396
- throw new Error(`Creating bookings requires an event ID in format "{id}-{bookingId}-{projectId}", got: ${event.id}`);
397
- }
398
-
399
- const durationMs = event.end.getTime() - event.start.getTime();
400
- const duration = this.formatDuration(durationMs);
401
- const date = event.start.toISOString().split('T')[0];
402
- const description = event.description || '';
403
-
404
- const params = new URLSearchParams({
405
- employee_id: this.credentials.employeeId,
406
- unit_id: this.credentials.unitId || '',
407
- date,
408
- description,
409
- project_id: parsed.projectId.toString(),
410
- duration,
411
- create: '',
412
- });
413
-
414
- const result = await this.apiQueryRequest<{ id: number }>('/timetracks', 'POST', params);
415
-
416
- if (!result.id) {
417
- throw new Error('Failed to create booking: invalid response from server');
418
- }
419
-
420
- const newEventId = `${result.id}:0:${parsed.projectId}`;
421
-
422
- return {
423
- ...event,
424
- id: newEventId,
425
- calendar: this.name,
426
- color: this.color,
427
- sourceId: this.id,
428
- readOnly: false,
429
- };
430
- }
431
-
432
- /**
433
- * Update an existing booking in the Inhouse Booking System.
434
- */
435
- async updateEvent(event: CalendarEvent, updates: Partial<CalendarEvent>): Promise<CalendarEvent> {
436
- if (!this.enabled) {
437
- throw new Error('Inhouse Booking System source is disabled');
438
- }
439
-
440
- const id = event.id;
441
- const parsedId = this.parseEventId(id);
442
- if (!parsedId) {
443
- throw new Error(`Invalid event ID format: ${id}`);
444
- }
445
-
446
- // Fetch existing booking to get current values (match by API id, first segment)
447
- const existingBookings = await this.fetchEvents();
448
- const existing = existingBookings.find(e => e.id.startsWith(`${parsedId.id}:`));
449
- if (!existing) {
450
- throw new Error(`Booking not found: ${id}`);
451
- }
452
-
453
- // Use current values or fall back to existing
454
- const start = updates.start ?? existing.start;
455
- const end = updates.end ?? existing.end;
456
- const durationMs = end.getTime() - start.getTime();
457
- const duration = this.formatDuration(durationMs);
458
- const date = start.toISOString().split('T')[0];
459
- const description = updates.description ?? existing.description ?? '';
460
-
461
- const body = new URLSearchParams({
462
- id: parsedId.resourceId.toString(),
463
- unit_id: this.credentials.unitId || '',
464
- employee_id: this.credentials.employeeId,
465
- booking_id: parsedId.bookingId.toString(),
466
- date,
467
- description,
468
- project_id: parsedId.projectId.toString(),
469
- duration,
470
- update: '',
471
- });
472
-
473
- await this.apiWriteRequest<unknown>(`/timetracks/${parsedId.resourceId}`, 'PUT', body);
474
-
475
- return {
476
- ...existing,
477
- ...updates,
478
- id,
479
- calendar: this.name,
480
- color: this.color,
481
- sourceId: this.id,
482
- readOnly: false,
483
- };
484
- }
485
-
486
- /**
487
- * Delete a booking from the Inhouse Booking System.
488
- * - Confirmed timetracks (id > 0): DELETE /timetracks/{id}?id=...&date=...&duration=...etc
489
- * - Raw bookings (id <= 0): DELETE /timetracks/delete_bookings/{bookingId}
490
- */
491
- async deleteEvent(id: string): Promise<void> {
492
- if (!this.enabled) {
493
- throw new Error('Inhouse Booking System source is disabled');
494
- }
495
-
496
- const parsedId = this.parseEventId(id);
497
- if (!parsedId) {
498
- throw new Error(`Invalid event ID format: ${id}`);
499
- }
500
-
501
- if (parsedId.id <= 0) {
502
- await this.apiQueryRequest<unknown>(`/timetracks/delete_bookings/${parsedId.bookingId}`, 'DELETE', new URLSearchParams());
503
- return;
504
- }
505
-
506
- const existingBookings = await this.fetchEvents();
507
- const existing = existingBookings.find(e => e.id === id);
508
- if (!existing) {
509
- throw new Error(`Booking not found: ${id}`);
510
- }
511
-
512
- const durationMs = existing.end.getTime() - existing.start.getTime();
513
- const duration = this.formatDuration(durationMs);
514
- const date = existing.start.toISOString().split('T')[0];
515
- const description = existing.description ?? '';
516
-
517
- const params = new URLSearchParams({
518
- id: parsedId.id.toString(),
519
- unit_id: this.credentials.unitId || '',
520
- employee_id: this.credentials.employeeId,
521
- booking_id: parsedId.bookingId.toString(),
522
- date,
523
- description,
524
- project_id: parsedId.projectId.toString(),
525
- duration,
526
- });
527
-
528
- await this.apiQueryRequest<unknown>(`/timetracks/${parsedId.id}`, 'DELETE', params);
529
- }
530
-
531
- /**
532
- * Test the connection to the Inhouse Booking System.
533
- * Returns true if the credentials are valid.
534
- */
535
- async testConnection(): Promise<boolean> {
536
- try {
537
- const now = new Date();
538
- const dateFilter = {
539
- '>=': now.toISOString().split('T')[0],
540
- '<=': now.toISOString().split('T')[0],
541
- };
542
- const employeeFilter = { '=': Number.parseInt(this.credentials.employeeId, 10) };
543
-
544
- const params = new URLSearchParams({
545
- date: JSON.stringify(dateFilter),
546
- employee_id: JSON.stringify(employeeFilter),
547
- limit: '1',
548
- });
549
-
550
- await this.apiRequest<InhouseBooking[]>('/timetracks/with_bookings', params);
551
- return true;
552
- } catch {
553
- return false;
554
- }
555
- }
71
+ readonly type = "inhouse";
72
+ credentials: InhouseCredentials;
73
+ enabled: boolean;
74
+
75
+ constructor(
76
+ public id: string,
77
+ public name: string,
78
+ public color: string,
79
+ credentials: InhouseCredentials,
80
+ enabled = true,
81
+ ) {
82
+ this.credentials = credentials;
83
+ this.enabled = enabled;
84
+ }
85
+
86
+ /**
87
+ * Make an authenticated request to the Inhouse Booking API via the proxy.
88
+ * The proxy will inject the session cookie.
89
+ */
90
+ private async apiRequest<T>(
91
+ endpoint: string,
92
+ searchParams?: URLSearchParams,
93
+ ): Promise<T> {
94
+ const url = searchParams
95
+ ? `${endpoint}?${searchParams.toString()}`
96
+ : endpoint;
97
+
98
+ const response = await fetch(url, {
99
+ headers: {
100
+ "X-Session-Cookie": this.credentials.sessionCookie,
101
+ },
102
+ });
103
+
104
+ if (!response.ok) {
105
+ if (response.status === 401) {
106
+ throw new Error(
107
+ "Inhouse Booking System authentication failed. Please check your session cookie.",
108
+ );
109
+ }
110
+ throw new Error(
111
+ `Inhouse Booking API error: ${response.status} ${response.statusText}`,
112
+ );
113
+ }
114
+
115
+ return response.json() as Promise<T>;
116
+ }
117
+
118
+ /**
119
+ * Convert an Inhouse booking to internal CalendarEvent format.
120
+ * Bookings use date + duration (format "HH:MM").
121
+ * Bookings are ordered by their position in the day (first booking = starts at 09:00).
122
+ */
123
+ private mapBookingToEvent(
124
+ booking: InhouseBooking,
125
+ startTime: Date,
126
+ ): CalendarEvent | null {
127
+ if (!booking.date || !booking.duration) return null;
128
+
129
+ // Parse duration string "HH:MM" to hours
130
+ const [hours, minutes] = booking.duration.split(":").map(Number);
131
+ if (hours === undefined || minutes === undefined) return null;
132
+
133
+ const durationMs = (hours * 60 + minutes) * 60 * 1000;
134
+ const endTime = new Date(startTime.getTime() + durationMs);
135
+
136
+ if (Number.isNaN(startTime.getTime()) || Number.isNaN(endTime.getTime()))
137
+ return null;
138
+
139
+ const projectName = booking.project[0]?.name || "";
140
+ const eventId = `${booking.id}:${booking.booking_id}:${booking.project_id}`;
141
+
142
+ // Events with id <= 0 are raw bookings (no timetrack record yet); render as TENTATIVE.
143
+ const isRawBooking = booking.id <= 0;
144
+
145
+ return {
146
+ id: eventId,
147
+ title: projectName,
148
+ start: startTime,
149
+ end: endTime,
150
+ color: this.color,
151
+ calendar: this.name,
152
+ calendarId: this.id,
153
+ sourceId: this.id,
154
+ description: booking.description || "",
155
+ readOnly: false,
156
+ status: booking.optional === 1 || isRawBooking ? "TENTATIVE" : undefined,
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Process bookings and calculate their timestamps based on order.
162
+ * Bookings are stacked chronologically within a 9-hour workday starting at 09:00,
163
+ * split into a morning and afternoon session by a 1-hour lunch break.
164
+ *
165
+ * The split is determined by the midpoint of total work duration:
166
+ * bookings are greedily assigned to the morning until adding the next one
167
+ * would exceed half the day's total duration. The remaining bookings are
168
+ * placed after the 1-hour lunch break.
169
+ *
170
+ * Example: 3.5h + 4.5h = 8h total → lunch after first (3.5h ≤ 4h),
171
+ * result: 09:00-12:30, lunch 12:30-13:30, 13:30-18:00.
172
+ */
173
+ private processBookings(bookings: InhouseBooking[]): CalendarEvent[] {
174
+ const bookingsByDate = new Map<string, InhouseBooking[]>();
175
+
176
+ for (const booking of bookings) {
177
+ if (!booking.date) continue;
178
+ const list = bookingsByDate.get(booking.date) || [];
179
+ list.push(booking);
180
+ bookingsByDate.set(booking.date, list);
181
+ }
182
+
183
+ const events: CalendarEvent[] = [];
184
+
185
+ for (const [dateStr, dateBookings] of bookingsByDate) {
186
+ const [year, month, day] = dateStr.split("-").map(Number);
187
+ if (!year || !month || !day) continue;
188
+
189
+ const durations = dateBookings.map((b) => {
190
+ if (!b.duration) return 0;
191
+ const [h, m] = b.duration.split(":").map(Number);
192
+ if (h === undefined || m === undefined) return 0;
193
+ return (h * 60 + m) * 60 * 1000;
194
+ });
195
+
196
+ const halfMs = durations.reduce((a, b) => a + b, 0) / 2;
197
+
198
+ // Greedy split: assign bookings to morning until the next one would exceed half
199
+ let morningMs = 0;
200
+ let splitIndex = 0;
201
+ for (let i = 0; i < dateBookings.length; i++) {
202
+ if (morningMs + (durations[i] ?? 0) <= halfMs) {
203
+ morningMs += durations[i] ?? 0;
204
+ splitIndex = i + 1;
205
+ } else {
206
+ break;
207
+ }
208
+ }
209
+
210
+ const LUNCH_MS = 60 * 60 * 1000;
211
+ let currentTime = new Date(
212
+ year,
213
+ month - 1,
214
+ day,
215
+ this.credentials.startHour ?? 9,
216
+ 0,
217
+ );
218
+
219
+ for (let i = 0; i < dateBookings.length; i++) {
220
+ // Insert 1-hour lunch break between morning and afternoon sessions
221
+ if (i === splitIndex && splitIndex > 0) {
222
+ currentTime = new Date(currentTime.getTime() + LUNCH_MS);
223
+ }
224
+
225
+ const event = this.mapBookingToEvent(
226
+ dateBookings[i] as InhouseBooking,
227
+ currentTime,
228
+ );
229
+ if (event) {
230
+ events.push(event);
231
+ currentTime = event.end;
232
+ }
233
+ }
234
+ }
235
+
236
+ return events;
237
+ }
238
+
239
+ /**
240
+ * Fetch bookings from the Inhouse Booking System.
241
+ * Fetches bookings from 1 year ago to 1 year in the future.
242
+ */
243
+ async fetchEvents(): Promise<CalendarEvent[]> {
244
+ if (!this.enabled) return [];
245
+
246
+ const now = new Date();
247
+ const oneYearAgo = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000);
248
+ const oneYearFuture = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000);
249
+
250
+ const formatDate = (date: Date): string => date.toISOString().split("T")[0];
251
+
252
+ const dateFilter = {
253
+ ">=": formatDate(oneYearAgo),
254
+ "<=": formatDate(oneYearFuture),
255
+ };
256
+
257
+ const employeeFilter = {
258
+ "=": Number.parseInt(this.credentials.employeeId, 10),
259
+ };
260
+
261
+ const params = new URLSearchParams({
262
+ date: JSON.stringify(dateFilter),
263
+ employee_id: JSON.stringify(employeeFilter),
264
+ });
265
+
266
+ const bookings = await this.apiRequest<InhouseBooking[]>(
267
+ "/timetracks/with_bookings",
268
+ params,
269
+ );
270
+
271
+ if (!Array.isArray(bookings)) return [];
272
+
273
+ return this.processBookings(bookings);
274
+ }
275
+
276
+ /**
277
+ * Fetch the list of active projects.
278
+ * Returns projects as {id, name} pairs for use in the project picker.
279
+ */
280
+ async fetchProjects(): Promise<Array<{ id: number; name: string }>> {
281
+ const params = new URLSearchParams({
282
+ archived: JSON.stringify({ "=": 0 }),
283
+ });
284
+ const projects = await this.apiRequest<Array<{ id: number; name: string }>>(
285
+ "/projects",
286
+ params,
287
+ );
288
+ if (!Array.isArray(projects)) return [];
289
+ return projects;
290
+ }
291
+
292
+ /**
293
+ * Extract XSRF-TOKEN value from session cookie.
294
+ * Returns the encrypted token value that Laravel can decrypt.
295
+ */
296
+ private getXsrfToken(): string | undefined {
297
+ const match = this.credentials.sessionCookie.match(/XSRF-TOKEN=([^;]+)/);
298
+ return match ? decodeURIComponent(match[1]) : undefined;
299
+ }
300
+
301
+ /**
302
+ * Make an authenticated write request to the Inhouse Booking API via the proxy.
303
+ * Uses X-XSRF-TOKEN header with the encrypted token (Laravel decrypts this automatically).
304
+ */
305
+ private async apiWriteRequest<T>(
306
+ endpoint: string,
307
+ method: string,
308
+ body: URLSearchParams,
309
+ ): Promise<T> {
310
+ const xsrfToken = this.getXsrfToken();
311
+
312
+ if (!xsrfToken) {
313
+ throw new Error(
314
+ "XSRF-TOKEN not found in session cookie. Please check your credentials.",
315
+ );
316
+ }
317
+
318
+ const headers: Record<string, string> = {
319
+ "X-Session-Cookie": this.credentials.sessionCookie,
320
+ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
321
+ "X-Requested-With": "XMLHttpRequest",
322
+ "X-XSRF-TOKEN": xsrfToken,
323
+ };
324
+
325
+ const response = await fetch(endpoint, {
326
+ method,
327
+ headers,
328
+ body: body.toString(),
329
+ });
330
+
331
+ if (!response.ok) {
332
+ if (response.status === 401) {
333
+ throw new Error(
334
+ "Inhouse Booking System authentication failed. Please check your session cookie.",
335
+ );
336
+ }
337
+ if (response.status === 403) {
338
+ throw new Error(
339
+ "Inhouse Booking System CSRF token invalid or missing.",
340
+ );
341
+ }
342
+ throw new Error(
343
+ `Inhouse Booking API error: ${response.status} ${response.statusText}`,
344
+ );
345
+ }
346
+
347
+ const text = await response.text();
348
+ if (!text) return {} as T;
349
+
350
+ try {
351
+ return JSON.parse(text) as T;
352
+ } catch {
353
+ return {} as T;
354
+ }
355
+ }
356
+
357
+ /**
358
+ * Make an authenticated write request with params as query string and empty body.
359
+ * This matches the Inhouse API format used by the browser client for creating bookings.
360
+ */
361
+ private async apiQueryRequest<T>(
362
+ endpoint: string,
363
+ method: string,
364
+ params: URLSearchParams,
365
+ ): Promise<T> {
366
+ const xsrfToken = this.getXsrfToken();
367
+
368
+ if (!xsrfToken) {
369
+ throw new Error(
370
+ "XSRF-TOKEN not found in session cookie. Please check your credentials.",
371
+ );
372
+ }
373
+
374
+ const url = `${endpoint}/?${params.toString()}`;
375
+
376
+ const response = await fetch(url, {
377
+ method,
378
+ headers: {
379
+ "X-Session-Cookie": this.credentials.sessionCookie,
380
+ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
381
+ "X-Requested-With": "XMLHttpRequest",
382
+ "X-XSRF-TOKEN": xsrfToken,
383
+ },
384
+ });
385
+
386
+ if (!response.ok) {
387
+ if (response.status === 401) {
388
+ throw new Error(
389
+ "Inhouse Booking System authentication failed. Please check your session cookie.",
390
+ );
391
+ }
392
+ if (response.status === 403) {
393
+ throw new Error(
394
+ "Inhouse Booking System CSRF token invalid or missing.",
395
+ );
396
+ }
397
+ throw new Error(
398
+ `Inhouse Booking API error: ${response.status} ${response.statusText}`,
399
+ );
400
+ }
401
+
402
+ const text = await response.text();
403
+ if (!text) return {} as T;
404
+
405
+ try {
406
+ return JSON.parse(text) as T;
407
+ } catch {
408
+ return {} as T;
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Format duration from milliseconds to "HH:MM" format.
414
+ */
415
+ private formatDuration(durationMs: number): string {
416
+ const totalMinutes = Math.round(durationMs / (60 * 1000));
417
+ const hours = Math.floor(totalMinutes / 60);
418
+ const minutes = totalMinutes % 60;
419
+ return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(
420
+ 2,
421
+ "0",
422
+ )}`;
423
+ }
424
+
425
+ /**
426
+ * Parse event ID in format "{id}:{booking_id}:{project_id}".
427
+ * The API resource ID is bookingId when id is unavailable (< 0).
428
+ */
429
+ private parseEventId(eventId: string): {
430
+ id: number;
431
+ bookingId: number;
432
+ projectId: number;
433
+ resourceId: number;
434
+ } | null {
435
+ const parts = eventId.split(":");
436
+ if (parts.length !== 3) return null;
437
+ const id = Number.parseInt(parts[0], 10);
438
+ const bookingId = Number.parseInt(parts[1], 10);
439
+ const projectId = Number.parseInt(parts[2], 10);
440
+ if (Number.isNaN(id) || Number.isNaN(bookingId) || Number.isNaN(projectId))
441
+ return null;
442
+ const resourceId = id > 0 ? id : bookingId;
443
+ return { id, bookingId, projectId, resourceId };
444
+ }
445
+
446
+ /**
447
+ * Create a new booking in the Inhouse Booking System.
448
+ * Params are sent as query string with empty body, matching the Inhouse API format.
449
+ */
450
+ async createEvent(
451
+ event: Omit<CalendarEvent, "calendar" | "color">,
452
+ ): Promise<CalendarEvent> {
453
+ if (!this.enabled) {
454
+ throw new Error("Inhouse Booking System source is disabled");
455
+ }
456
+
457
+ const parsed = this.parseEventId(event.id);
458
+ if (!parsed) {
459
+ throw new Error(
460
+ `Creating bookings requires an event ID in format "{id}-{bookingId}-{projectId}", got: ${event.id}`,
461
+ );
462
+ }
463
+
464
+ const durationMs = event.end.getTime() - event.start.getTime();
465
+ const duration = this.formatDuration(durationMs);
466
+ const date = event.start.toISOString().split("T")[0];
467
+ const description = event.description || "";
468
+
469
+ const params = new URLSearchParams({
470
+ employee_id: this.credentials.employeeId,
471
+ unit_id: this.credentials.unitId || "",
472
+ date,
473
+ description,
474
+ project_id: parsed.projectId.toString(),
475
+ duration,
476
+ create: "",
477
+ });
478
+
479
+ const result = await this.apiQueryRequest<{ id: number }>(
480
+ "/timetracks",
481
+ "POST",
482
+ params,
483
+ );
484
+
485
+ if (!result.id) {
486
+ throw new Error("Failed to create booking: invalid response from server");
487
+ }
488
+
489
+ const newEventId = `${result.id}:0:${parsed.projectId}`;
490
+
491
+ return {
492
+ ...event,
493
+ id: newEventId,
494
+ calendar: this.name,
495
+ color: this.color,
496
+ sourceId: this.id,
497
+ readOnly: false,
498
+ };
499
+ }
500
+
501
+ /**
502
+ * Update an existing booking in the Inhouse Booking System.
503
+ */
504
+ async updateEvent(
505
+ event: CalendarEvent,
506
+ updates: Partial<CalendarEvent>,
507
+ ): Promise<CalendarEvent> {
508
+ if (!this.enabled) {
509
+ throw new Error("Inhouse Booking System source is disabled");
510
+ }
511
+
512
+ const id = event.id;
513
+ const parsedId = this.parseEventId(id);
514
+ if (!parsedId) {
515
+ throw new Error(`Invalid event ID format: ${id}`);
516
+ }
517
+
518
+ // Fetch existing booking to get current values (match by API id, first segment)
519
+ const existingBookings = await this.fetchEvents();
520
+ const existing = existingBookings.find((e) =>
521
+ e.id.startsWith(`${parsedId.id}:`),
522
+ );
523
+ if (!existing) {
524
+ throw new Error(`Booking not found: ${id}`);
525
+ }
526
+
527
+ // Use current values or fall back to existing
528
+ const start = updates.start ?? existing.start;
529
+ const end = updates.end ?? existing.end;
530
+ const durationMs = end.getTime() - start.getTime();
531
+ const duration = this.formatDuration(durationMs);
532
+ const date = start.toISOString().split("T")[0];
533
+ const description = updates.description ?? existing.description ?? "";
534
+
535
+ const body = new URLSearchParams({
536
+ id: parsedId.resourceId.toString(),
537
+ unit_id: this.credentials.unitId || "",
538
+ employee_id: this.credentials.employeeId,
539
+ booking_id: parsedId.bookingId.toString(),
540
+ date,
541
+ description,
542
+ project_id: parsedId.projectId.toString(),
543
+ duration,
544
+ update: "",
545
+ });
546
+
547
+ await this.apiWriteRequest<unknown>(
548
+ `/timetracks/${parsedId.resourceId}`,
549
+ "PUT",
550
+ body,
551
+ );
552
+
553
+ return {
554
+ ...existing,
555
+ ...updates,
556
+ id,
557
+ calendar: this.name,
558
+ color: this.color,
559
+ sourceId: this.id,
560
+ readOnly: false,
561
+ };
562
+ }
563
+
564
+ /**
565
+ * Delete a booking from the Inhouse Booking System.
566
+ * - Confirmed timetracks (id > 0): DELETE /timetracks/{id}?id=...&date=...&duration=...etc
567
+ * - Raw bookings (id <= 0): DELETE /timetracks/delete_bookings/{bookingId}
568
+ */
569
+ async deleteEvent(id: string): Promise<void> {
570
+ if (!this.enabled) {
571
+ throw new Error("Inhouse Booking System source is disabled");
572
+ }
573
+
574
+ const parsedId = this.parseEventId(id);
575
+ if (!parsedId) {
576
+ throw new Error(`Invalid event ID format: ${id}`);
577
+ }
578
+
579
+ if (parsedId.id <= 0) {
580
+ await this.apiQueryRequest<unknown>(
581
+ `/timetracks/delete_bookings/${parsedId.bookingId}`,
582
+ "DELETE",
583
+ new URLSearchParams(),
584
+ );
585
+ return;
586
+ }
587
+
588
+ const existingBookings = await this.fetchEvents();
589
+ const existing = existingBookings.find((e) => e.id === id);
590
+ if (!existing) {
591
+ throw new Error(`Booking not found: ${id}`);
592
+ }
593
+
594
+ const durationMs = existing.end.getTime() - existing.start.getTime();
595
+ const duration = this.formatDuration(durationMs);
596
+ const date = existing.start.toISOString().split("T")[0];
597
+ const description = existing.description ?? "";
598
+
599
+ const params = new URLSearchParams({
600
+ id: parsedId.id.toString(),
601
+ unit_id: this.credentials.unitId || "",
602
+ employee_id: this.credentials.employeeId,
603
+ booking_id: parsedId.bookingId.toString(),
604
+ date,
605
+ description,
606
+ project_id: parsedId.projectId.toString(),
607
+ duration,
608
+ });
609
+
610
+ await this.apiQueryRequest<unknown>(
611
+ `/timetracks/${parsedId.id}`,
612
+ "DELETE",
613
+ params,
614
+ );
615
+ }
616
+
617
+ /**
618
+ * Test the connection to the Inhouse Booking System.
619
+ * Returns true if the credentials are valid.
620
+ */
621
+ async testConnection(): Promise<boolean> {
622
+ try {
623
+ const now = new Date();
624
+ const dateFilter = {
625
+ ">=": now.toISOString().split("T")[0],
626
+ "<=": now.toISOString().split("T")[0],
627
+ };
628
+ const employeeFilter = {
629
+ "=": Number.parseInt(this.credentials.employeeId, 10),
630
+ };
631
+
632
+ const params = new URLSearchParams({
633
+ date: JSON.stringify(dateFilter),
634
+ employee_id: JSON.stringify(employeeFilter),
635
+ limit: "1",
636
+ });
637
+
638
+ await this.apiRequest<InhouseBooking[]>(
639
+ "/timetracks/with_bookings",
640
+ params,
641
+ );
642
+ return true;
643
+ } catch {
644
+ return false;
645
+ }
646
+ }
556
647
  }