@luckydye/calendar 1.1.2 → 1.1.3
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/dist/calendar.js +1387 -1301
- package/package.json +1 -1
- package/src/CalDAVConfig.ts +25 -0
- package/src/CalendarIntegration.ts +1 -2
- package/src/CalendarInternal.ts +20 -1
- package/src/CalendarView.ts +474 -257
- package/src/GoogleCalendarSource.ts +0 -7
- package/src/InMemorySource.ts +0 -15
- package/src/InhouseBookingSource.ts +331 -46
- package/src/Keybinds.ts +46 -0
- package/src/app.css +28 -0
- package/src/app.ts +173 -63
|
@@ -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
|
*/
|
package/src/InMemorySource.ts
CHANGED
|
@@ -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., "
|
|
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:
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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 =
|
|
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 +
|
|
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.
|
|
111
|
+
if (!booking.date || !booking.duration) return null;
|
|
110
112
|
|
|
111
|
-
//
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
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:
|
|
122
|
-
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:
|
|
130
|
-
readOnly:
|
|
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 -
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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[]>(
|
|
516
|
+
await this.apiRequest<InhouseBooking[]>('/timetracks/with_bookings', params);
|
|
232
517
|
return true;
|
|
233
|
-
} catch
|
|
518
|
+
} catch {
|
|
234
519
|
return false;
|
|
235
520
|
}
|
|
236
521
|
}
|
package/src/Keybinds.ts
ADDED
|
@@ -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
|
+
}
|