@luckydye/calendar 1.3.0 → 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
 
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.ts CHANGED
@@ -12,6 +12,7 @@ import { initializeTheme } from "./Theme.js";
12
12
  import { InMemorySource } from "./InMemorySource.js";
13
13
  import { GoogleCalendarSource } from "./GoogleCalendarSource.js";
14
14
  import { InhouseBookingSource } from "./InhouseBookingSource.js";
15
+ import { fetchTimeseriesJsonEvents } from "./TimeseriesJson.js";
15
16
  import { queueStatus } from "./StatusMessage.js";
16
17
  import { activeCalendarStore } from "./ActiveCalendarStore.js";
17
18
  import type { CalendarEvent } from "./CalendarInternal.js";
@@ -44,7 +45,6 @@ registerKeybinds(
44
45
  { key: "Escape", action: () => calendarElement.escape() },
45
46
  { key: "t", action: () => calendarElement.scrollToToday() },
46
47
  ],
47
- calendarElement,
48
48
  );
49
49
 
50
50
  let workerPromise: Promise<ServiceWorkerRegistration> | undefined;
@@ -80,6 +80,7 @@ function updateStatusBar() {
80
80
 
81
81
  // Update statusbar on relevant events
82
82
  calendarElement.addEventListener("selection-change", updateStatusBar);
83
+ calendarElement.addEventListener("meta-key-change", updateStatusBar);
83
84
 
84
85
  // Update statusbar periodically for current time
85
86
  setInterval(updateStatusBar, 10000); // Every 10 seconds
@@ -311,6 +312,29 @@ function createICalCalendar(source: CalendarSource): Calendar {
311
312
  };
312
313
  }
313
314
 
315
+ // Create a Timeseries JSON calendar wrapper
316
+ function createTimeseriesJsonCalendar(source: CalendarSource): Calendar {
317
+ return {
318
+ id: source.id,
319
+ name: source.name,
320
+ color: source.color,
321
+ enabled: source.enabled,
322
+ locked: (source as { locked?: boolean }).locked,
323
+ sourceId: source.id,
324
+ sourceType: "timeseries-json",
325
+
326
+ async fetchEvents(): Promise<CalendarEvent[]> {
327
+ return fetchTimeseriesJsonEvents(
328
+ source.credentials as { url: string; timestampField?: string; titleField?: string },
329
+ source.color,
330
+ source.name,
331
+ source.id,
332
+ );
333
+ },
334
+ // Timeseries JSON calendars are read-only by default
335
+ };
336
+ }
337
+
314
338
  // Create a Google Calendar wrapper
315
339
  function createGoogleCalendar(source: CalendarSource): Calendar {
316
340
  const calendarId = source.credentials.calendarId || "primary";
@@ -538,6 +562,21 @@ async function syncCalDAV(force = false) {
538
562
  } catch (error) {
539
563
  console.error(`Sync error for iCal source ${source.name}:`, error);
540
564
  }
565
+ } else if (source.type === "timeseries-json") {
566
+ if (!source.credentials?.url) {
567
+ console.warn(`Skipping Timeseries JSON source ${source.name}: missing URL`);
568
+ continue;
569
+ }
570
+
571
+ const calendar = createTimeseriesJsonCalendar(source);
572
+ loadedCalendars.set(calendar.id, calendar);
573
+ activeCalendarStore.registerCalendar(calendar);
574
+
575
+ try {
576
+ await sync(calendar, { force });
577
+ } catch (error) {
578
+ console.error(`Sync error for Timeseries JSON source ${source.name}:`, error);
579
+ }
541
580
  } else if (source.type === "google") {
542
581
  if (!source.credentials?.accessToken) {
543
582
  console.warn(`Skipping Google Calendar source ${source.name}: missing access token`);
@@ -722,7 +761,7 @@ function showProjectPickerDialog(
722
761
  }
723
762
 
724
763
  calendarElement.addEventListener("create-event", async (e) => {
725
- const { start, end } = e.detail;
764
+ const { start, end, isAllDay } = e.detail;
726
765
  const calendar = getActiveCalendar();
727
766
  if (!calendar) {
728
767
  queueStatus("No calendar selected. Please configure a calendar first.");
@@ -751,6 +790,7 @@ calendarElement.addEventListener("create-event", async (e) => {
751
790
  title: "New Event",
752
791
  start,
753
792
  end,
793
+ isAllDay,
754
794
  calendar: calendar.name,
755
795
  calendarId: calendar.calendarUrl ?? calendar.id,
756
796
  sourceId: calendar.sourceId,
@@ -758,7 +798,7 @@ calendarElement.addEventListener("create-event", async (e) => {
758
798
  lastSynced: new Date(),
759
799
  });
760
800
  try {
761
- await withMutation(calendar, () => calendar.createEvent({ id, title: "New Event", start, end }));
801
+ await withMutation(calendar, () => calendar.createEvent({ id, title: "New Event", start, end, isAllDay }));
762
802
  } catch (error) {
763
803
  calendarElement.internal.removeEventOptimistically(id);
764
804
  console.error("Failed to create event:", error);
@@ -992,6 +1032,7 @@ calendarElement.addEventListener("import-ical", async (e) => {
992
1032
  function updateActiveCalendarColor() {
993
1033
  const calendar = activeCalendarStore.getActiveCalendar();
994
1034
  calendarElement.activeCalendarColor = calendar?.color ?? null;
1035
+ calendarElement.activeCalendarId = activeCalendarStore.getActiveId();
995
1036
  }
996
1037
 
997
1038
  function updateEnabledCalendars() {
@@ -1027,4 +1068,3 @@ function updateLockedCalendars() {
1027
1068
  });
1028
1069
  calendar.setLockedCalendars(lockedCalendarIdentifiers);
1029
1070
  }
1030
-