@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.
- package/dist/calendar.js +1548 -1373
- package/package.json +2 -2
- package/src/CalDAVConfig.ts +50 -1
- package/src/CalendarInternal.ts +9 -4
- package/src/CalendarLayer.ts +28 -0
- package/src/CalendarView.ts +277 -1110
- package/src/GoogleCalendarSource.ts +25 -0
- package/src/IndexedDBStorage.ts +3 -0
- package/src/Keybinds.ts +3 -18
- package/src/StatusBar.ts +11 -0
- package/src/Theme.ts +4 -4
- package/src/TimeseriesJson.ts +114 -0
- package/src/app.ts +44 -4
- package/src/layers/EventsLayer.ts +958 -0
- package/src/layers/GridLayer.ts +296 -0
- package/src/layers/TimeseriesHeatmapLayer.ts +132 -0
- package/src/lib.ts +1 -0
|
@@ -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
|
);
|
package/src/IndexedDBStorage.ts
CHANGED
|
@@ -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
|
-
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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
|
-
|