@luckydye/calendar 1.1.2 → 1.2.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.
@@ -471,13 +471,6 @@ export class GoogleCalendarSource implements CalendarSource {
471
471
  return updated;
472
472
  }
473
473
 
474
- /**
475
- * Move an event to new start/end times.
476
- */
477
- async moveEvent(id: string, newStart: Date, newEnd: Date): Promise<CalendarEvent> {
478
- return this.updateEvent(id, { start: newStart, end: newEnd });
479
- }
480
-
481
474
  /**
482
475
  * Delete an event from Google Calendar.
483
476
  */
@@ -58,21 +58,6 @@ export class InMemorySource implements CalendarSource {
58
58
  return updated;
59
59
  }
60
60
 
61
- async moveEvent(id: string, newStart: Date, newEnd: Date): Promise<CalendarEvent> {
62
- const event = this.events.get(id);
63
- if (!event) {
64
- throw new Error(`Event ${id} not found`);
65
- }
66
-
67
- const moved = {
68
- ...event,
69
- start: newStart,
70
- end: newEnd,
71
- };
72
- this.events.set(id, moved);
73
- return moved;
74
- }
75
-
76
61
  async deleteEvent(id: string): Promise<void> {
77
62
  const deleted = this.events.delete(id);
78
63
  if (!deleted) {
@@ -6,7 +6,7 @@ import type { CalendarSource, CalendarCredentials } from "./CalendarIntegration.
6
6
  */
7
7
  export interface InhouseCredentials extends CalendarCredentials {
8
8
  /**
9
- * Session cookie for authentication (e.g., "sessionid=abc123; csrftoken=xyz789")
9
+ * Session cookie for authentication (e.g., "XSRF-TOKEN=xxx; laravel_session=yyy")
10
10
  */
11
11
  sessionCookie: string;
12
12
  /**
@@ -23,22 +23,24 @@ export interface InhouseCredentials extends CalendarCredentials {
23
23
  * Inhouse Booking System API response structure.
24
24
  */
25
25
  interface InhouseBooking {
26
- id: string | number;
26
+ id: number;
27
27
  date: string;
28
- timeslots: number;
29
28
  description: string;
29
+ duration: string;
30
+ project_id: number;
30
31
  employee_id: number;
31
- closing_id?: number;
32
- project_id?: number;
33
- owner_id?: number;
34
- updated_user_id?: number;
35
- home_office?: number;
36
- out_of_office?: number;
32
+ booking_id: number;
33
+ closing_id: number | null;
34
+ project: Array<{
35
+ id: number;
36
+ name: string;
37
+ report: number;
38
+ }>;
39
+ account: Array<{
40
+ id: number;
41
+ name: string;
42
+ }>;
37
43
  optional?: number;
38
- deadline?: number;
39
- created_at?: string;
40
- updated_at?: string;
41
- deleted_at?: string | null;
42
44
  }
43
45
 
44
46
  /**
@@ -78,8 +80,8 @@ export class InhouseBookingSource implements CalendarSource {
78
80
  * Make an authenticated request to the Inhouse Booking API via the proxy.
79
81
  * The proxy will inject the session cookie.
80
82
  */
81
- private async apiRequest<T>(endpoint: string): Promise<T> {
82
- const url = `/bookings${endpoint}`;
83
+ private async apiRequest<T>(endpoint: string, searchParams?: URLSearchParams): Promise<T> {
84
+ const url = searchParams ? `${endpoint}?${searchParams.toString()}` : endpoint;
83
85
 
84
86
  const response = await fetch(url, {
85
87
  headers: {
@@ -99,47 +101,61 @@ export class InhouseBookingSource implements CalendarSource {
99
101
 
100
102
  /**
101
103
  * Convert an Inhouse booking to internal CalendarEvent format.
102
- * Bookings use date + timeslots (each quarter = 2 hours in an 8-hour work day).
104
+ * Bookings use date + duration (format "HH:MM").
103
105
  * Bookings are ordered by their position in the day (first booking = starts at 09:00).
104
106
  */
105
107
  private mapBookingToEvent(
106
108
  booking: InhouseBooking,
107
109
  startTime: Date
108
110
  ): CalendarEvent | null {
109
- if (!booking.date || !booking.timeslots) return null;
111
+ if (!booking.date || !booking.duration) return null;
110
112
 
111
- // Each quarter = 2 hours (8-hour day / 4 quarters)
112
- const quarterHours = 2;
113
- const durationHours = booking.timeslots * quarterHours;
114
- const endTime = new Date(startTime.getTime() + durationHours * 60 * 60 * 1000);
113
+ // Parse duration string "HH:MM" to hours
114
+ const [hours, minutes] = booking.duration.split(':').map(Number);
115
+ if (hours === undefined || minutes === undefined) return null;
116
+
117
+ const durationMs = (hours * 60 + minutes) * 60 * 1000;
118
+ const endTime = new Date(startTime.getTime() + durationMs);
115
119
 
116
120
  if (Number.isNaN(startTime.getTime()) || Number.isNaN(endTime.getTime())) return null;
117
121
 
118
- const title = booking.description || 'Booking';
122
+ const projectName = booking.project[0]?.name || '';
123
+ const eventId = `${booking.id}:${booking.booking_id}:${booking.project_id}`;
124
+
125
+ // Events with id <= 0 are raw bookings (no timetrack record yet); render as TENTATIVE.
126
+ const isRawBooking = booking.id <= 0;
119
127
 
120
128
  return {
121
- id: String(booking.id),
122
- title: title,
129
+ id: eventId,
130
+ title: projectName,
123
131
  start: startTime,
124
132
  end: endTime,
125
133
  color: this.color,
126
134
  calendar: this.name,
127
135
  calendarId: this.id,
128
136
  sourceId: this.id,
129
- description: `${booking.timeslots} quarter${booking.timeslots > 1 ? 's' : ''} (${durationHours}h)`,
130
- readOnly: true,
131
- status: booking.optional === 1 ? 'TENTATIVE' : undefined,
137
+ description: booking.description || '',
138
+ readOnly: false,
139
+ status: (booking.optional === 1 || isRawBooking) ? 'TENTATIVE' : undefined,
132
140
  };
133
141
  }
134
142
 
135
143
  /**
136
144
  * Process bookings and calculate their timestamps based on order.
137
- * Bookings are stacked chronologically - first booking starts at 09:00.
145
+ * Bookings are stacked chronologically within a 9-hour workday starting at 09:00,
146
+ * split into a morning and afternoon session by a 1-hour lunch break.
147
+ *
148
+ * The split is determined by the midpoint of total work duration:
149
+ * bookings are greedily assigned to the morning until adding the next one
150
+ * would exceed half the day's total duration. The remaining bookings are
151
+ * placed after the 1-hour lunch break.
152
+ *
153
+ * Example: 3.5h + 4.5h = 8h total → lunch after first (3.5h ≤ 4h),
154
+ * result: 09:00-12:30, lunch 12:30-13:30, 13:30-18:00.
138
155
  */
139
156
  private processBookings(bookings: InhouseBooking[]): CalendarEvent[] {
140
- // Group bookings by date
141
157
  const bookingsByDate = new Map<string, InhouseBooking[]>();
142
-
158
+
143
159
  for (const booking of bookings) {
144
160
  if (!booking.date) continue;
145
161
  const list = bookingsByDate.get(booking.date) || [];
@@ -150,19 +166,42 @@ export class InhouseBookingSource implements CalendarSource {
150
166
  const events: CalendarEvent[] = [];
151
167
 
152
168
  for (const [dateStr, dateBookings] of bookingsByDate) {
153
- // Parse date (format: "2025-03-07")
154
169
  const [year, month, day] = dateStr.split('-').map(Number);
155
170
  if (!year || !month || !day) continue;
156
171
 
157
- // Start of work day: 09:00
172
+ const durations = dateBookings.map(b => {
173
+ if (!b.duration) return 0;
174
+ const [h, m] = b.duration.split(':').map(Number);
175
+ if (h === undefined || m === undefined) return 0;
176
+ return (h * 60 + m) * 60 * 1000;
177
+ });
178
+
179
+ const halfMs = durations.reduce((a, b) => a + b, 0) / 2;
180
+
181
+ // Greedy split: assign bookings to morning until the next one would exceed half
182
+ let morningMs = 0;
183
+ let splitIndex = 0;
184
+ for (let i = 0; i < dateBookings.length; i++) {
185
+ if (morningMs + (durations[i] ?? 0) <= halfMs) {
186
+ morningMs += durations[i] ?? 0;
187
+ splitIndex = i + 1;
188
+ } else {
189
+ break;
190
+ }
191
+ }
192
+
193
+ const LUNCH_MS = 60 * 60 * 1000;
158
194
  let currentTime = new Date(year, month - 1, day, 9, 0);
159
195
 
160
- // Process bookings in order (API returns them in chronological order)
161
- for (const booking of dateBookings) {
162
- const event = this.mapBookingToEvent(booking, currentTime);
196
+ for (let i = 0; i < dateBookings.length; i++) {
197
+ // Insert 1-hour lunch break between morning and afternoon sessions
198
+ if (i === splitIndex && splitIndex > 0) {
199
+ currentTime = new Date(currentTime.getTime() + LUNCH_MS);
200
+ }
201
+
202
+ const event = this.mapBookingToEvent(dateBookings[i] as InhouseBooking, currentTime);
163
203
  if (event) {
164
204
  events.push(event);
165
- // Advance current time for next booking
166
205
  currentTime = event.end;
167
206
  }
168
207
  }
@@ -190,7 +229,7 @@ export class InhouseBookingSource implements CalendarSource {
190
229
  };
191
230
 
192
231
  const employeeFilter = {
193
- '=': this.credentials.employeeId,
232
+ '=': Number.parseInt(this.credentials.employeeId, 10),
194
233
  };
195
234
 
196
235
  const params = new URLSearchParams({
@@ -198,17 +237,263 @@ export class InhouseBookingSource implements CalendarSource {
198
237
  employee_id: JSON.stringify(employeeFilter),
199
238
  });
200
239
 
201
- if (this.credentials.unitId) {
202
- params.set('unit_id', this.credentials.unitId);
203
- }
204
-
205
- const bookings = await this.apiRequest<InhouseBooking[]>(`?${params}`);
240
+ const bookings = await this.apiRequest<InhouseBooking[]>('/timetracks/with_bookings', params);
206
241
 
207
242
  if (!Array.isArray(bookings)) return [];
208
243
 
209
244
  return this.processBookings(bookings);
210
245
  }
211
246
 
247
+ /**
248
+ * Fetch the list of active projects.
249
+ * Returns projects as {id, name} pairs for use in the project picker.
250
+ */
251
+ async fetchProjects(): Promise<Array<{ id: number; name: string }>> {
252
+ const params = new URLSearchParams({
253
+ archived: JSON.stringify({ '=': 0 }),
254
+ });
255
+ const projects = await this.apiRequest<Array<{ id: number; name: string }>>('/projects', params);
256
+ if (!Array.isArray(projects)) return [];
257
+ return projects;
258
+ }
259
+
260
+ /**
261
+ * Extract XSRF-TOKEN value from session cookie.
262
+ * Returns the encrypted token value that Laravel can decrypt.
263
+ */
264
+ private getXsrfToken(): string | undefined {
265
+ const match = this.credentials.sessionCookie.match(/XSRF-TOKEN=([^;]+)/);
266
+ return match ? decodeURIComponent(match[1]) : undefined;
267
+ }
268
+
269
+ /**
270
+ * Make an authenticated write request to the Inhouse Booking API via the proxy.
271
+ * Uses X-XSRF-TOKEN header with the encrypted token (Laravel decrypts this automatically).
272
+ */
273
+ private async apiWriteRequest<T>(endpoint: string, method: string, body: URLSearchParams): Promise<T> {
274
+ const xsrfToken = this.getXsrfToken();
275
+
276
+ if (!xsrfToken) {
277
+ throw new Error('XSRF-TOKEN not found in session cookie. Please check your credentials.');
278
+ }
279
+
280
+ const headers: Record<string, string> = {
281
+ 'X-Session-Cookie': this.credentials.sessionCookie,
282
+ 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
283
+ 'X-Requested-With': 'XMLHttpRequest',
284
+ 'X-XSRF-TOKEN': xsrfToken,
285
+ };
286
+
287
+ const response = await fetch(endpoint, {
288
+ method,
289
+ headers,
290
+ body: body.toString(),
291
+ });
292
+
293
+ if (!response.ok) {
294
+ if (response.status === 401) {
295
+ throw new Error('Inhouse Booking System authentication failed. Please check your session cookie.');
296
+ }
297
+ if (response.status === 403) {
298
+ throw new Error('Inhouse Booking System CSRF token invalid or missing.');
299
+ }
300
+ throw new Error(`Inhouse Booking API error: ${response.status} ${response.statusText}`);
301
+ }
302
+
303
+ const text = await response.text();
304
+ if (!text) return {} as T;
305
+
306
+ try {
307
+ return JSON.parse(text) as T;
308
+ } catch {
309
+ return {} as T;
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Make an authenticated write request with params as query string and empty body.
315
+ * This matches the Inhouse API format used by the browser client for creating bookings.
316
+ */
317
+ private async apiQueryRequest<T>(endpoint: string, method: string, params: URLSearchParams): Promise<T> {
318
+ const xsrfToken = this.getXsrfToken();
319
+
320
+ if (!xsrfToken) {
321
+ throw new Error('XSRF-TOKEN not found in session cookie. Please check your credentials.');
322
+ }
323
+
324
+ const url = `${endpoint}/?${params.toString()}`;
325
+
326
+ const response = await fetch(url, {
327
+ method,
328
+ headers: {
329
+ 'X-Session-Cookie': this.credentials.sessionCookie,
330
+ 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
331
+ 'X-Requested-With': 'XMLHttpRequest',
332
+ 'X-XSRF-TOKEN': xsrfToken,
333
+ },
334
+ });
335
+
336
+ if (!response.ok) {
337
+ if (response.status === 401) {
338
+ throw new Error('Inhouse Booking System authentication failed. Please check your session cookie.');
339
+ }
340
+ if (response.status === 403) {
341
+ throw new Error('Inhouse Booking System CSRF token invalid or missing.');
342
+ }
343
+ throw new Error(`Inhouse Booking API error: ${response.status} ${response.statusText}`);
344
+ }
345
+
346
+ const text = await response.text();
347
+ if (!text) return {} as T;
348
+
349
+ try {
350
+ return JSON.parse(text) as T;
351
+ } catch {
352
+ return {} as T;
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Format duration from milliseconds to "HH:MM" format.
358
+ */
359
+ private formatDuration(durationMs: number): string {
360
+ const totalMinutes = Math.round(durationMs / (60 * 1000));
361
+ const hours = Math.floor(totalMinutes / 60);
362
+ const minutes = totalMinutes % 60;
363
+ return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
364
+ }
365
+
366
+ /**
367
+ * Parse event ID in format "{id}:{booking_id}:{project_id}".
368
+ * The API resource ID is bookingId when id is unavailable (< 0).
369
+ */
370
+ private parseEventId(eventId: string): { id: number; bookingId: number; projectId: number; resourceId: number } | null {
371
+ const parts = eventId.split(':');
372
+ if (parts.length !== 3) return null;
373
+ const id = Number.parseInt(parts[0], 10);
374
+ const bookingId = Number.parseInt(parts[1], 10);
375
+ const projectId = Number.parseInt(parts[2], 10);
376
+ if (Number.isNaN(id) || Number.isNaN(bookingId) || Number.isNaN(projectId)) return null;
377
+ const resourceId = id > 0 ? id : bookingId;
378
+ return { id, bookingId, projectId, resourceId };
379
+ }
380
+
381
+ /**
382
+ * Create a new booking in the Inhouse Booking System.
383
+ * Params are sent as query string with empty body, matching the Inhouse API format.
384
+ */
385
+ async createEvent(event: Omit<CalendarEvent, 'calendar' | 'color'>): Promise<CalendarEvent> {
386
+ if (!this.enabled) {
387
+ throw new Error('Inhouse Booking System source is disabled');
388
+ }
389
+
390
+ const parsed = this.parseEventId(event.id);
391
+ if (!parsed) {
392
+ throw new Error(`Creating bookings requires an event ID in format "{id}-{bookingId}-{projectId}", got: ${event.id}`);
393
+ }
394
+
395
+ const durationMs = event.end.getTime() - event.start.getTime();
396
+ const duration = this.formatDuration(durationMs);
397
+ const date = event.start.toISOString().split('T')[0];
398
+ const description = event.description || '';
399
+
400
+ const params = new URLSearchParams({
401
+ employee_id: this.credentials.employeeId,
402
+ unit_id: this.credentials.unitId || '',
403
+ date,
404
+ description,
405
+ project_id: parsed.projectId.toString(),
406
+ duration,
407
+ create: '',
408
+ });
409
+
410
+ const result = await this.apiQueryRequest<{ id: number }>('/timetracks', 'POST', params);
411
+
412
+ if (!result.id) {
413
+ throw new Error('Failed to create booking: invalid response from server');
414
+ }
415
+
416
+ const newEventId = `${result.id}:0:${parsed.projectId}`;
417
+
418
+ return {
419
+ ...event,
420
+ id: newEventId,
421
+ calendar: this.name,
422
+ color: this.color,
423
+ sourceId: this.id,
424
+ readOnly: false,
425
+ };
426
+ }
427
+
428
+ /**
429
+ * Update an existing booking in the Inhouse Booking System.
430
+ */
431
+ async updateEvent(id: string, updates: Partial<CalendarEvent>): Promise<CalendarEvent> {
432
+ if (!this.enabled) {
433
+ throw new Error('Inhouse Booking System source is disabled');
434
+ }
435
+
436
+ const parsedId = this.parseEventId(id);
437
+ if (!parsedId) {
438
+ throw new Error(`Invalid event ID format: ${id}`);
439
+ }
440
+
441
+ // Fetch existing booking to get current values (match by API id, first segment)
442
+ const existingBookings = await this.fetchEvents();
443
+ const existing = existingBookings.find(e => e.id.startsWith(`${parsedId.id}:`));
444
+ if (!existing) {
445
+ throw new Error(`Booking not found: ${id}`);
446
+ }
447
+
448
+ // Use current values or fall back to existing
449
+ const start = updates.start ?? existing.start;
450
+ const end = updates.end ?? existing.end;
451
+ const durationMs = end.getTime() - start.getTime();
452
+ const duration = this.formatDuration(durationMs);
453
+ const date = start.toISOString().split('T')[0];
454
+ const description = updates.description ?? existing.description ?? '';
455
+
456
+ const body = new URLSearchParams({
457
+ id: parsedId.resourceId.toString(),
458
+ unit_id: this.credentials.unitId || '',
459
+ employee_id: this.credentials.employeeId,
460
+ booking_id: parsedId.bookingId.toString(),
461
+ date,
462
+ description,
463
+ project_id: parsedId.projectId.toString(),
464
+ duration,
465
+ update: '',
466
+ });
467
+
468
+ await this.apiWriteRequest<unknown>(`/timetracks/${parsedId.resourceId}`, 'PUT', body);
469
+
470
+ return {
471
+ ...existing,
472
+ ...updates,
473
+ id,
474
+ calendar: this.name,
475
+ color: this.color,
476
+ sourceId: this.id,
477
+ readOnly: false,
478
+ };
479
+ }
480
+
481
+ /**
482
+ * Delete a booking from the Inhouse Booking System.
483
+ */
484
+ async deleteEvent(id: string): Promise<void> {
485
+ if (!this.enabled) {
486
+ throw new Error('Inhouse Booking System source is disabled');
487
+ }
488
+
489
+ const parsedId = this.parseEventId(id);
490
+ if (!parsedId) {
491
+ throw new Error(`Invalid event ID format: ${id}`);
492
+ }
493
+
494
+ await this.apiQueryRequest<unknown>(`/timetracks/delete_bookings/${parsedId.bookingId}`, 'DELETE', new URLSearchParams());
495
+ }
496
+
212
497
  /**
213
498
  * Test the connection to the Inhouse Booking System.
214
499
  * Returns true if the credentials are valid.
@@ -220,17 +505,17 @@ export class InhouseBookingSource implements CalendarSource {
220
505
  '>=': now.toISOString().split('T')[0],
221
506
  '<=': now.toISOString().split('T')[0],
222
507
  };
223
- const employeeFilter = { '=': this.credentials.employeeId };
224
-
508
+ const employeeFilter = { '=': Number.parseInt(this.credentials.employeeId, 10) };
509
+
225
510
  const params = new URLSearchParams({
226
511
  date: JSON.stringify(dateFilter),
227
512
  employee_id: JSON.stringify(employeeFilter),
228
513
  limit: '1',
229
514
  });
230
515
 
231
- await this.apiRequest<InhouseBooking[]>(`?${params}`);
516
+ await this.apiRequest<InhouseBooking[]>('/timetracks/with_bookings', params);
232
517
  return true;
233
- } catch (error) {
518
+ } catch {
234
519
  return false;
235
520
  }
236
521
  }
@@ -0,0 +1,46 @@
1
+ const INPUT_SELECTOR = 'input, textarea, [contenteditable="true"]';
2
+
3
+ function isInputFocused(shadowHost: Element): boolean {
4
+ const active = document.activeElement;
5
+ if (active?.matches(INPUT_SELECTOR)) return true;
6
+ // Shadow DOM: the host element appears as activeElement when focus is inside it
7
+ if (active === shadowHost) {
8
+ const shadowActive = shadowHost.shadowRoot?.activeElement;
9
+ if (shadowActive?.matches(INPUT_SELECTOR)) return true;
10
+ }
11
+ return false;
12
+ }
13
+
14
+ export interface Keybind {
15
+ key: string;
16
+ /** Matches Cmd (Mac) or Ctrl (Win/Linux) */
17
+ cmdOrCtrl?: boolean;
18
+ shift?: boolean;
19
+ action: () => void;
20
+ }
21
+
22
+ export function registerKeybinds(
23
+ bindings: Keybind[],
24
+ shadowHost: Element,
25
+ ): () => void {
26
+ const handler = (e: KeyboardEvent): void => {
27
+ if (isInputFocused(shadowHost)) return;
28
+
29
+ for (const binding of bindings) {
30
+ const keyMatch = e.key === binding.key;
31
+ const cmdMatch = binding.cmdOrCtrl
32
+ ? e.metaKey || e.ctrlKey
33
+ : !e.metaKey && !e.ctrlKey;
34
+ const shiftMatch = binding.shift ? e.shiftKey : !e.shiftKey;
35
+
36
+ if (keyMatch && cmdMatch && shiftMatch) {
37
+ e.preventDefault();
38
+ binding.action();
39
+ return;
40
+ }
41
+ }
42
+ };
43
+
44
+ window.addEventListener("keydown", handler);
45
+ return () => window.removeEventListener("keydown", handler);
46
+ }
package/src/app.css CHANGED
@@ -2,3 +2,31 @@ caldav-config {
2
2
  height: 100%;
3
3
  box-shadow: 0 0 24px rgba(0, 0, 0, 0.4);
4
4
  }
5
+
6
+ .toolbar-button {
7
+ background: transparent;
8
+ border: 1px solid var(--grid-color, rgba(255, 255, 255, 0.1));
9
+ color: var(--text-primary, rgba(255, 255, 255, 0.9));
10
+ padding: 4px 8px;
11
+ border-radius: var(--border-radius-sm, 4px);
12
+ font-size: 13px;
13
+ line-height: 16px;
14
+ cursor: pointer;
15
+ display: flex;
16
+ align-items: center;
17
+ gap: 6px;
18
+ transition: background 0.15s, border-color 0.15s;
19
+ }
20
+
21
+ .toolbar-button:hover {
22
+ background: var(--bg-button-hover, rgba(255, 255, 255, 0.05));
23
+ border-color: var(--grid-color-hover, rgba(255, 255, 255, 0.2));
24
+ }
25
+
26
+ .toolbar-button:active {
27
+ background: var(--bg-button-active, rgba(255, 255, 255, 0.1));
28
+ }
29
+
30
+ .toolbar-button[disabled] {
31
+ opacity: 0.5;
32
+ }