@luckydye/calendar 1.2.3 → 1.3.1

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.
@@ -430,6 +430,31 @@ export class GoogleCalendarSource implements CalendarSource {
430
430
  * Update an existing event in Google Calendar.
431
431
  */
432
432
  async updateEvent(id: string, updates: Partial<CalendarEvent>): Promise<CalendarEvent> {
433
+ const isRsvpOnly = Object.keys(updates).length === 1 && updates.attendees !== undefined;
434
+
435
+ if (isRsvpOnly) {
436
+ // PATCH with only attendees — allowed for non-organizers updating their own response status
437
+ const response = await this.apiRequest<GoogleCalendarEvent>(
438
+ `/calendars/${encodeURIComponent(this.calendarId)}/events/${encodeURIComponent(id)}`,
439
+ {
440
+ method: 'PATCH',
441
+ body: JSON.stringify({
442
+ attendees: updates.attendees!.map(att => ({
443
+ email: att.email,
444
+ displayName: att.name,
445
+ responseStatus: this.mapToGoogleResponseStatus(att.status),
446
+ optional: att.role === 'OPT-PARTICIPANT',
447
+ })),
448
+ }),
449
+ }
450
+ );
451
+ const updated = this.mapGoogleEvent(response);
452
+ if (!updated) {
453
+ throw new Error('Failed to update event: invalid response from Google Calendar');
454
+ }
455
+ return updated;
456
+ }
457
+
433
458
  const existing = await this.apiRequest<GoogleCalendarEvent>(
434
459
  `/calendars/${encodeURIComponent(this.calendarId)}/events/${encodeURIComponent(id)}`
435
460
  );
@@ -21,6 +21,7 @@ interface SerializedEvent {
21
21
  status?: 'TENTATIVE' | 'CONFIRMED' | 'CANCELLED';
22
22
  isAllDay?: boolean;
23
23
  reminders?: NotificationConfig[];
24
+ visualStyle?: 'heatmap';
24
25
  }
25
26
 
26
27
  interface SerializedMetadata {
@@ -326,6 +327,7 @@ export class IndexedDBStorage implements CalendarStorage {
326
327
  status: event.status,
327
328
  isAllDay: event.isAllDay,
328
329
  reminders: event.reminders,
330
+ visualStyle: event.visualStyle,
329
331
  };
330
332
  }
331
333
 
@@ -375,6 +377,7 @@ export class IndexedDBStorage implements CalendarStorage {
375
377
  status: serialized.status,
376
378
  isAllDay: serialized.isAllDay,
377
379
  reminders: serialized.reminders,
380
+ visualStyle: serialized.visualStyle,
378
381
  };
379
382
  }
380
383
 
@@ -390,4 +393,17 @@ export class IndexedDBStorage implements CalendarStorage {
390
393
  transaction.onerror = () => reject(transaction.error);
391
394
  });
392
395
  }
396
+
397
+ async deleteEvent(id: string): Promise<void> {
398
+ const db = await this.open();
399
+
400
+ return new Promise((resolve, reject) => {
401
+ const transaction = db.transaction(this.storeName, "readwrite");
402
+ const store = transaction.objectStore(this.storeName);
403
+ store.delete(id);
404
+
405
+ transaction.oncomplete = () => resolve();
406
+ transaction.onerror = () => reject(transaction.error);
407
+ });
408
+ }
393
409
  }
@@ -484,6 +484,8 @@ export class InhouseBookingSource implements CalendarSource {
484
484
 
485
485
  /**
486
486
  * Delete a booking from the Inhouse Booking System.
487
+ * - Confirmed timetracks (id > 0): DELETE /timetracks/{id}?id=...&date=...&duration=...etc
488
+ * - Raw bookings (id <= 0): DELETE /timetracks/delete_bookings/{bookingId}
487
489
  */
488
490
  async deleteEvent(id: string): Promise<void> {
489
491
  if (!this.enabled) {
@@ -495,7 +497,34 @@ export class InhouseBookingSource implements CalendarSource {
495
497
  throw new Error(`Invalid event ID format: ${id}`);
496
498
  }
497
499
 
498
- await this.apiQueryRequest<unknown>(`/timetracks/delete_bookings/${parsedId.bookingId}`, 'DELETE', new URLSearchParams());
500
+ if (parsedId.id <= 0) {
501
+ await this.apiQueryRequest<unknown>(`/timetracks/delete_bookings/${parsedId.bookingId}`, 'DELETE', new URLSearchParams());
502
+ return;
503
+ }
504
+
505
+ const existingBookings = await this.fetchEvents();
506
+ const existing = existingBookings.find(e => e.id === id);
507
+ if (!existing) {
508
+ throw new Error(`Booking not found: ${id}`);
509
+ }
510
+
511
+ const durationMs = existing.end.getTime() - existing.start.getTime();
512
+ const duration = this.formatDuration(durationMs);
513
+ const date = existing.start.toISOString().split('T')[0];
514
+ const description = existing.description ?? '';
515
+
516
+ const params = new URLSearchParams({
517
+ id: parsedId.id.toString(),
518
+ unit_id: this.credentials.unitId || '',
519
+ employee_id: this.credentials.employeeId,
520
+ booking_id: parsedId.bookingId.toString(),
521
+ date,
522
+ description,
523
+ project_id: parsedId.projectId.toString(),
524
+ duration,
525
+ });
526
+
527
+ await this.apiQueryRequest<unknown>(`/timetracks/${parsedId.id}`, 'DELETE', params);
499
528
  }
500
529
 
501
530
  /**
package/src/Keybinds.ts CHANGED
@@ -1,16 +1,3 @@
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
1
  export interface Keybind {
15
2
  key: string;
16
3
  /** Matches Cmd (Mac) or Ctrl (Win/Linux) */
@@ -19,12 +6,10 @@ export interface Keybind {
19
6
  action: () => void;
20
7
  }
21
8
 
22
- export function registerKeybinds(
23
- bindings: Keybind[],
24
- shadowHost: Element,
25
- ): () => void {
9
+ export function registerKeybinds(bindings: Keybind[]): () => void {
26
10
  const handler = (e: KeyboardEvent): void => {
27
- if (isInputFocused(shadowHost)) return;
11
+ const focused = e.composedPath()[0] as HTMLElement;
12
+ if (focused?.tagName === "INPUT" || focused?.tagName === "TEXTAREA" || focused?.isContentEditable) return;
28
13
 
29
14
  for (const binding of bindings) {
30
15
  const keyMatch = e.key === binding.key;
package/src/StatusBar.ts CHANGED
@@ -10,6 +10,7 @@ export interface StatusBarData {
10
10
  formattedDate: string;
11
11
  formattedTime: string;
12
12
  formattedCursorDate: string;
13
+ altKeyActive: boolean;
13
14
  }
14
15
 
15
16
  export class StatusBarElement extends LitElement {
@@ -62,6 +63,14 @@ export class StatusBarElement extends LitElement {
62
63
  .status-value {
63
64
  color: var(--text-secondary, rgba(255, 255, 255, 0.7));
64
65
  }
66
+
67
+ .meta-key {
68
+ padding: 1px 5px;
69
+ border-radius: 3px;
70
+ border: 1px solid var(--text-muted, rgba(255, 255, 255, 0.4));
71
+ font-size: 9px;
72
+ color: var(--text-secondary, rgba(255, 255, 255, 0.7));
73
+ }
65
74
  `;
66
75
 
67
76
  render() {
@@ -82,6 +91,8 @@ export class StatusBarElement extends LitElement {
82
91
  </div>
83
92
 
84
93
  <status-message></status-message>
94
+
95
+ ${this.data.altKeyActive ? html`<span class="meta-key">Meta</span>` : html``}
85
96
  </div>
86
97
 
87
98
  <div class="status-bar-right">
package/src/Theme.ts CHANGED
@@ -23,7 +23,7 @@ export const themes: Record<ConcreteThemeName, ThemeDefinition> = {
23
23
  "--bg-button-active": "rgba(255, 255, 255, 0.1)",
24
24
  "--bg-today": "rgba(255, 255, 255, 0.05)",
25
25
  "--bg-selection": "rgba(255, 255, 255, 0.1)",
26
- "--bg-weekend": "rgba(255, 255, 255, 0.03)",
26
+ "--bg-weekend": "transparent",
27
27
  "--bg-item": "rgba(255, 255, 255, 0.05)",
28
28
  "--bg-item-hover": "rgba(255, 255, 255, 0.08)",
29
29
  "--grid-color": "rgba(255, 255, 255, 0.1)",
@@ -59,7 +59,7 @@ export const themes: Record<ConcreteThemeName, ThemeDefinition> = {
59
59
  "--bg-button-active": "rgba(0, 0, 0, 0.08)",
60
60
  "--bg-today": "rgba(0, 100, 255, 0.08)",
61
61
  "--bg-selection": "rgba(0, 100, 255, 0.15)",
62
- "--bg-weekend": "rgba(0, 0, 0, 0.02)",
62
+ "--bg-weekend": "transparent",
63
63
  "--bg-item": "rgba(0, 0, 0, 0.03)",
64
64
  "--bg-item-hover": "rgba(0, 0, 0, 0.06)",
65
65
  "--grid-color": "rgba(0, 0, 0, 0.08)",
@@ -95,7 +95,7 @@ export const themes: Record<ConcreteThemeName, ThemeDefinition> = {
95
95
  "--bg-button-active": "rgba(255, 255, 255, 0.1)",
96
96
  "--bg-today": "rgba(181, 137, 0, 0.15)",
97
97
  "--bg-selection": "rgba(181, 137, 0, 0.25)",
98
- "--bg-weekend": "rgba(255, 255, 255, 0.03)",
98
+ "--bg-weekend": "transparent",
99
99
  "--bg-item": "rgba(255, 255, 255, 0.05)",
100
100
  "--bg-item-hover": "rgba(255, 255, 255, 0.08)",
101
101
  "--grid-color": "rgba(131, 148, 150, 0.2)",
@@ -131,7 +131,7 @@ export const themes: Record<ConcreteThemeName, ThemeDefinition> = {
131
131
  "--bg-button-active": "rgb(60, 60, 60)",
132
132
  "--bg-today": "rgb(30, 30, 30)",
133
133
  "--bg-selection": "rgb(255, 255, 0)",
134
- "--bg-weekend": "rgba(255, 255, 255, 0.05)",
134
+ "--bg-weekend": "transparent",
135
135
  "--bg-item": "rgb(20, 20, 20)",
136
136
  "--bg-item-hover": "rgb(40, 40, 40)",
137
137
  "--grid-color": "rgb(80, 80, 80)",
@@ -0,0 +1,114 @@
1
+ import type { CalendarCredentials } from "./CalendarIntegration.js";
2
+ import type { CalendarEvent } from "./CalendarInternal.js";
3
+
4
+ export interface TimeseriesJsonCredentials extends CalendarCredentials {
5
+ url: string;
6
+ timestampField?: string;
7
+ titleField?: string;
8
+ }
9
+
10
+ function parseTimestamp(value: unknown): Date | null {
11
+ if (!value) return null;
12
+ if (value instanceof Date) {
13
+ return Number.isNaN(value.getTime()) ? null : value;
14
+ }
15
+ if (typeof value === "number") {
16
+ const normalized = value < 1_000_000_000_000 ? value * 1000 : value;
17
+ const date = new Date(normalized);
18
+ return Number.isNaN(date.getTime()) ? null : date;
19
+ }
20
+ if (typeof value === "string") {
21
+ const date = new Date(value);
22
+ return Number.isNaN(date.getTime()) ? null : date;
23
+ }
24
+ return null;
25
+ }
26
+
27
+ function readStringField(
28
+ record: Record<string, unknown>,
29
+ field: string,
30
+ ): string | undefined {
31
+ const value = record[field];
32
+ if (typeof value !== "string") return undefined;
33
+ const trimmed = value.trim();
34
+ return trimmed ? trimmed : undefined;
35
+ }
36
+
37
+ export async function fetchTimeseriesJsonEvents(
38
+ credentials: TimeseriesJsonCredentials,
39
+ color: string,
40
+ calendar: string,
41
+ sourceId?: string,
42
+ ): Promise<CalendarEvent[]> {
43
+ if (!credentials.url) {
44
+ throw new Error("Timeseries JSON URL is required");
45
+ }
46
+
47
+ const response = await fetch(credentials.url, { cache: "no-store" });
48
+ if (!response.ok) {
49
+ throw new Error(
50
+ `Failed to fetch Timeseries JSON: ${response.status} ${response.statusText}`,
51
+ );
52
+ }
53
+
54
+ const data = await response.json();
55
+ if (!Array.isArray(data)) {
56
+ throw new Error("Timeseries JSON must be an array of objects");
57
+ }
58
+
59
+ const timestampField = credentials.timestampField || "timestamp";
60
+ const titleField = credentials.titleField || "title";
61
+ const defaultDurationMinutes = 1;
62
+
63
+ const events: CalendarEvent[] = [];
64
+
65
+ data.forEach((raw, index) => {
66
+ if (!raw || typeof raw !== "object") return;
67
+ const record = raw as Record<string, unknown>;
68
+
69
+ const start = parseTimestamp(record[timestampField]);
70
+ if (!start) return;
71
+
72
+ const isAllDay = Boolean(record.isAllDay ?? record.allDay);
73
+ const durationMinutes =
74
+ typeof record.durationMinutes === "number" &&
75
+ Number.isFinite(record.durationMinutes) &&
76
+ record.durationMinutes > 0
77
+ ? record.durationMinutes
78
+ : defaultDurationMinutes;
79
+
80
+ let end =
81
+ parseTimestamp(record.end) ||
82
+ parseTimestamp(record.endTimestamp) ||
83
+ parseTimestamp(record.endTime);
84
+
85
+ if (!end || end <= start) {
86
+ const fallbackDuration = isAllDay ? 24 * 60 : durationMinutes;
87
+ end = new Date(start.getTime() + fallbackDuration * 60 * 1000);
88
+ }
89
+
90
+ const title = readStringField(record, titleField) || calendar;
91
+ const id =
92
+ readStringField(record, "id") ||
93
+ `${sourceId || calendar}-${start.getTime()}-${index}`;
94
+
95
+ events.push({
96
+ id,
97
+ title,
98
+ start,
99
+ end,
100
+ color,
101
+ calendar,
102
+ calendarId: sourceId,
103
+ sourceId,
104
+ description: readStringField(record, "description"),
105
+ location: readStringField(record, "location"),
106
+ url: readStringField(record, "url"),
107
+ readOnly: true,
108
+ isAllDay: isAllDay || undefined,
109
+ visualStyle: "heatmap",
110
+ });
111
+ });
112
+
113
+ return events;
114
+ }
package/src/app.css CHANGED
@@ -1,6 +1,22 @@
1
+ dialog {
2
+ background: var(--bg-tertiary);
3
+ }
4
+
5
+ .caldav-sidebar {
6
+ width: 250px;
7
+ flex-shrink: 0;
8
+ height: 100%;
9
+ border-right: 1px solid var(--grid-color, rgba(255, 255, 255, 0.1));
10
+ overflow: hidden;
11
+ transition: width 0.15s ease;
12
+ }
13
+
14
+ .caldav-sidebar.collapsed {
15
+ width: 36px;
16
+ }
17
+
1
18
  caldav-config {
2
19
  height: 100%;
3
- box-shadow: 0 0 24px rgba(0, 0, 0, 0.4);
4
20
  }
5
21
 
6
22
  .toolbar-button {