@luckydye/calendar 1.3.2 → 1.4.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.
- package/dist/calendar.js +2344 -2061
- package/package.json +7 -1
- package/src/ActiveCalendarStore.ts +88 -88
- package/src/CalDAVConfig.ts +611 -514
- package/src/CalDAVSource.ts +561 -466
- package/src/CalendarIntegration.ts +64 -47
- package/src/CalendarInternal.ts +645 -614
- package/src/CalendarLayer.ts +1 -0
- package/src/CalendarStorage.ts +51 -48
- package/src/CalendarView.ts +883 -507
- package/src/Color.ts +48 -54
- package/src/GoogleCalendarSource.ts +758 -662
- package/src/ICal.ts +420 -348
- package/src/InMemorySource.ts +56 -48
- package/src/IndexedDBStorage.ts +444 -398
- package/src/InhouseBookingSource.ts +614 -523
- package/src/Keybinds.ts +6 -1
- package/src/NotificationScheduler.ts +11 -8
- package/src/StatusBar.ts +12 -8
- package/src/StatusMessage.ts +2 -2
- package/src/Theme.ts +21 -7
- package/src/TimeseriesJson.ts +98 -98
- package/src/app.ts +153 -78
- package/src/layers/EventsLayer.ts +530 -400
- package/src/layers/GridLayer.ts +45 -125
- package/src/layers/TimeseriesHeatmapLayer.ts +123 -120
- package/src/service-worker.js +3 -2
package/src/Keybinds.ts
CHANGED
|
@@ -9,7 +9,12 @@ export interface Keybind {
|
|
|
9
9
|
export function registerKeybinds(bindings: Keybind[]): () => void {
|
|
10
10
|
const handler = (e: KeyboardEvent): void => {
|
|
11
11
|
const focused = e.composedPath()[0] as HTMLElement;
|
|
12
|
-
if (
|
|
12
|
+
if (
|
|
13
|
+
focused?.tagName === "INPUT" ||
|
|
14
|
+
focused?.tagName === "TEXTAREA" ||
|
|
15
|
+
focused?.isContentEditable
|
|
16
|
+
)
|
|
17
|
+
return;
|
|
13
18
|
|
|
14
19
|
for (const binding of bindings) {
|
|
15
20
|
const keyMatch = e.key === binding.key;
|
|
@@ -11,12 +11,13 @@ export interface ScheduledNotification {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export class NotificationScheduler {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
constructor(
|
|
15
|
+
private workerPromise: () => Promise<ServiceWorkerRegistration>,
|
|
16
|
+
) {}
|
|
17
|
+
|
|
17
18
|
async scheduleEventNotifications(event: CalendarEvent): Promise<void> {
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
await this.cancelEventNotifications(event.id);
|
|
20
|
+
|
|
20
21
|
if (!event.reminders?.length) return;
|
|
21
22
|
|
|
22
23
|
const notifications = this.buildScheduledNotifications(event);
|
|
@@ -45,7 +46,9 @@ export class NotificationScheduler {
|
|
|
45
46
|
|
|
46
47
|
async getScheduledNotifications(): Promise<ScheduledNotification[]> {
|
|
47
48
|
try {
|
|
48
|
-
const response = await this.sendMessage({
|
|
49
|
+
const response = await this.sendMessage({
|
|
50
|
+
type: "GET_SCHEDULED_NOTIFICATIONS",
|
|
51
|
+
});
|
|
49
52
|
return response.notifications || [];
|
|
50
53
|
} catch (error) {
|
|
51
54
|
console.error("Failed to get scheduled notifications:", error);
|
|
@@ -77,8 +80,8 @@ export class NotificationScheduler {
|
|
|
77
80
|
}
|
|
78
81
|
|
|
79
82
|
async sendMessage(message: any): Promise<any> {
|
|
80
|
-
|
|
81
|
-
|
|
83
|
+
const worker = await this.workerPromise?.();
|
|
84
|
+
|
|
82
85
|
return new Promise((resolve, reject) => {
|
|
83
86
|
const channel = new MessageChannel();
|
|
84
87
|
channel.port1.onmessage = (e) => {
|
package/src/StatusBar.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { LitElement,
|
|
1
|
+
import { LitElement, css, html } from "lit";
|
|
2
2
|
import "./StatusMessage.ts";
|
|
3
3
|
|
|
4
4
|
export interface StatusBarData {
|
|
@@ -92,7 +92,11 @@ export class StatusBarElement extends LitElement {
|
|
|
92
92
|
|
|
93
93
|
<status-message></status-message>
|
|
94
94
|
|
|
95
|
-
${
|
|
95
|
+
${
|
|
96
|
+
this.data.altKeyActive
|
|
97
|
+
? html`<span class="meta-key">Meta</span>`
|
|
98
|
+
: html``
|
|
99
|
+
}
|
|
96
100
|
</div>
|
|
97
101
|
|
|
98
102
|
<div class="status-bar-right">
|
|
@@ -101,9 +105,9 @@ export class StatusBarElement extends LitElement {
|
|
|
101
105
|
? html`
|
|
102
106
|
<div class="status-item">
|
|
103
107
|
<span class="status-label">Selected:</span>
|
|
104
|
-
<span class="status-value">${
|
|
105
|
-
this.data.selectedEventsCount
|
|
106
|
-
}</span>
|
|
108
|
+
<span class="status-value">${
|
|
109
|
+
this.data.selectedEventsCount
|
|
110
|
+
} event${this.data.selectedEventsCount === 1 ? "" : "s"}</span>
|
|
107
111
|
</div>
|
|
108
112
|
<div class="status-item">
|
|
109
113
|
<span class="status-label">Duration:</span>
|
|
@@ -113,9 +117,9 @@ export class StatusBarElement extends LitElement {
|
|
|
113
117
|
: html`
|
|
114
118
|
<div class="status-item">
|
|
115
119
|
<span class="status-label">Total:</span>
|
|
116
|
-
<span class="status-value">${
|
|
117
|
-
this.data.totalEventsCount
|
|
118
|
-
}</span>
|
|
120
|
+
<span class="status-value">${
|
|
121
|
+
this.data.totalEventsCount
|
|
122
|
+
} event${this.data.totalEventsCount === 1 ? "" : "s"}</span>
|
|
119
123
|
</div>
|
|
120
124
|
`
|
|
121
125
|
}
|
package/src/StatusMessage.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { css, html
|
|
1
|
+
import { LitElement, css, html } from "lit";
|
|
2
2
|
|
|
3
3
|
interface QueuedMessage {
|
|
4
4
|
text: string;
|
|
@@ -116,7 +116,7 @@ export class StatusMessageElement extends LitElement {
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
try {
|
|
119
|
-
|
|
119
|
+
customElements.define("status-message", StatusMessageElement);
|
|
120
120
|
} catch (error) {
|
|
121
121
|
console.error("Failed to register custom element:", error);
|
|
122
122
|
}
|
package/src/Theme.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
|
-
export type ThemeName =
|
|
1
|
+
export type ThemeName =
|
|
2
|
+
| "auto"
|
|
3
|
+
| "dark"
|
|
4
|
+
| "light"
|
|
5
|
+
| "solarized"
|
|
6
|
+
| "high-contrast";
|
|
2
7
|
|
|
3
|
-
export type ConcreteThemeName =
|
|
8
|
+
export type ConcreteThemeName =
|
|
9
|
+
| "dark"
|
|
10
|
+
| "light"
|
|
11
|
+
| "solarized"
|
|
12
|
+
| "high-contrast";
|
|
4
13
|
|
|
5
14
|
export interface ThemeDefinition {
|
|
6
15
|
name: ThemeName;
|
|
@@ -159,7 +168,9 @@ let mediaQuery: MediaQueryList | null = null;
|
|
|
159
168
|
let onSystemThemeChange: (() => void) | null = null;
|
|
160
169
|
|
|
161
170
|
export function getSystemTheme(): ConcreteThemeName {
|
|
162
|
-
return window.matchMedia?.(
|
|
171
|
+
return window.matchMedia?.("(prefers-color-scheme: dark)")?.matches
|
|
172
|
+
? "dark"
|
|
173
|
+
: "light";
|
|
163
174
|
}
|
|
164
175
|
|
|
165
176
|
export function getEffectiveTheme(themeName: ThemeName): ConcreteThemeName {
|
|
@@ -176,7 +187,10 @@ function updateThemeMetaTag(bgColor: string): void {
|
|
|
176
187
|
}
|
|
177
188
|
}
|
|
178
189
|
|
|
179
|
-
export function applyTheme(
|
|
190
|
+
export function applyTheme(
|
|
191
|
+
themeName: ThemeName,
|
|
192
|
+
element: HTMLElement = document.body,
|
|
193
|
+
): void {
|
|
180
194
|
const effectiveTheme = getEffectiveTheme(themeName);
|
|
181
195
|
const theme = themes[effectiveTheme];
|
|
182
196
|
if (!theme) return;
|
|
@@ -208,15 +222,15 @@ export function loadThemePreference(): ThemeName {
|
|
|
208
222
|
export function initializeTheme(): void {
|
|
209
223
|
const theme = loadThemePreference();
|
|
210
224
|
applyTheme(theme);
|
|
211
|
-
|
|
225
|
+
|
|
212
226
|
// Listen for system theme changes when in auto mode
|
|
213
|
-
mediaQuery = window.matchMedia(
|
|
227
|
+
mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
|
214
228
|
onSystemThemeChange = () => {
|
|
215
229
|
if (loadThemePreference() === "auto") {
|
|
216
230
|
applyTheme("auto");
|
|
217
231
|
}
|
|
218
232
|
};
|
|
219
|
-
mediaQuery.addEventListener(
|
|
233
|
+
mediaQuery.addEventListener("change", onSystemThemeChange);
|
|
220
234
|
}
|
|
221
235
|
|
|
222
236
|
export const availableThemes = [
|
package/src/TimeseriesJson.ts
CHANGED
|
@@ -2,113 +2,113 @@ import type { CalendarCredentials } from "./CalendarIntegration.js";
|
|
|
2
2
|
import type { CalendarEvent } from "./CalendarInternal.js";
|
|
3
3
|
|
|
4
4
|
export interface TimeseriesJsonCredentials extends CalendarCredentials {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
url: string;
|
|
6
|
+
timestampField?: string;
|
|
7
|
+
titleField?: string;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
function parseTimestamp(value: unknown): Date | null {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
25
|
}
|
|
26
26
|
|
|
27
27
|
function readStringField(
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
record: Record<string, unknown>,
|
|
29
|
+
field: string,
|
|
30
30
|
): string | undefined {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
const value = record[field];
|
|
32
|
+
if (typeof value !== "string") return undefined;
|
|
33
|
+
const trimmed = value.trim();
|
|
34
|
+
return trimmed ? trimmed : undefined;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
export async function fetchTimeseriesJsonEvents(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
credentials: TimeseriesJsonCredentials,
|
|
39
|
+
color: string,
|
|
40
|
+
calendar: string,
|
|
41
|
+
sourceId?: string,
|
|
42
42
|
): Promise<CalendarEvent[]> {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
114
|
}
|