@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.
- package/dist/calendar.js +1635 -1446
- package/package.json +2 -2
- package/src/CalDAVConfig.ts +241 -117
- package/src/CalendarInternal.ts +21 -4
- package/src/CalendarLayer.ts +28 -0
- package/src/CalendarStorage.ts +5 -0
- package/src/CalendarView.ts +360 -1196
- package/src/GoogleCalendarSource.ts +25 -0
- package/src/IndexedDBStorage.ts +16 -0
- package/src/InhouseBookingSource.ts +30 -1
- 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.css +17 -1
- package/src/app.ts +211 -186
- 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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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.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 {
|