@luckydye/calendar 1.3.0 → 1.3.2
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 +2198 -1539
- package/package.json +2 -2
- package/src/CalDAVConfig.ts +50 -1
- package/src/CalDAVSource.ts +52 -19
- package/src/CalendarIntegration.ts +2 -2
- package/src/CalendarInternal.ts +10 -4
- package/src/CalendarLayer.ts +28 -0
- package/src/CalendarView.ts +517 -1146
- package/src/DescriptionSanitizer.ts +10 -0
- package/src/GoogleCalendarSource.ts +27 -1
- package/src/ICal.ts +7 -2
- package/src/InMemorySource.ts +6 -6
- package/src/IndexedDBStorage.ts +6 -0
- package/src/InhouseBookingSource.ts +2 -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.ts +199 -48
- 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
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luckydye/calendar",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.2",
|
|
4
4
|
"author": "Tim Havlicek",
|
|
5
5
|
"contributors": [],
|
|
6
6
|
"description": "",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"main": "dist/calendar.js",
|
|
9
|
-
"types": "src/
|
|
9
|
+
"types": "src/lib.ts",
|
|
10
10
|
"scripts": {
|
|
11
11
|
"build:app": "APP=true bunx --bun vite build --outDir=dist/calendar --base=./",
|
|
12
12
|
"build": "bunx --bun vite build",
|
package/src/CalDAVConfig.ts
CHANGED
|
@@ -44,7 +44,20 @@ interface InhouseSource extends CalendarSource {
|
|
|
44
44
|
locked?: boolean;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
interface TimeseriesJsonSource extends CalendarSource {
|
|
48
|
+
type: "timeseries-json";
|
|
49
|
+
credentials: {
|
|
50
|
+
url: string;
|
|
51
|
+
};
|
|
52
|
+
locked?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type ConfigurableSource =
|
|
56
|
+
| CalDAVSourceConfig
|
|
57
|
+
| ICalSource
|
|
58
|
+
| GoogleSource
|
|
59
|
+
| InhouseSource
|
|
60
|
+
| TimeseriesJsonSource;
|
|
48
61
|
|
|
49
62
|
interface SidebarCalendar {
|
|
50
63
|
id: string;
|
|
@@ -621,6 +634,22 @@ export class CalDAVConfigElement extends LitElement {
|
|
|
621
634
|
enabled: this.formData.enabled ?? true,
|
|
622
635
|
locked: this.formData.locked ?? false,
|
|
623
636
|
} as ICalSource;
|
|
637
|
+
} else if (this.formData.type === "timeseries-json") {
|
|
638
|
+
if (!this.formData.credentials?.url) {
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
source = {
|
|
643
|
+
id: this.editingId || crypto.randomUUID(),
|
|
644
|
+
name: this.formData.name,
|
|
645
|
+
type: "timeseries-json",
|
|
646
|
+
credentials: {
|
|
647
|
+
url: this.formData.credentials.url,
|
|
648
|
+
},
|
|
649
|
+
color: this.formData.color || "#06B6D4",
|
|
650
|
+
enabled: this.formData.enabled ?? true,
|
|
651
|
+
locked: this.formData.locked ?? false,
|
|
652
|
+
} as TimeseriesJsonSource;
|
|
624
653
|
} else if (this.formData.type === "google") {
|
|
625
654
|
if (!this.formData.credentials?.accessToken) {
|
|
626
655
|
alert('Please authenticate with Google before adding the calendar');
|
|
@@ -899,12 +928,15 @@ export class CalDAVConfigElement extends LitElement {
|
|
|
899
928
|
this.updateForm("color", "#4285F4");
|
|
900
929
|
} else if (type === "caldav" && !this.formData.color) {
|
|
901
930
|
this.updateForm("color", "#FF6E68");
|
|
931
|
+
} else if (type === "timeseries-json" && !this.formData.color) {
|
|
932
|
+
this.updateForm("color", "#06B6D4");
|
|
902
933
|
}
|
|
903
934
|
}}
|
|
904
935
|
?disabled=${isEditing}
|
|
905
936
|
>
|
|
906
937
|
<option value="caldav">CalDAV (with credentials)</option>
|
|
907
938
|
<option value="ical">iCal URL</option>
|
|
939
|
+
<option value="timeseries-json">Timeseries JSON (URL)</option>
|
|
908
940
|
<option value="google">Google Calendar</option>
|
|
909
941
|
<option value="inhouse">Inhouse Booking System</option>
|
|
910
942
|
</select>
|
|
@@ -972,6 +1004,23 @@ export class CalDAVConfigElement extends LitElement {
|
|
|
972
1004
|
/>
|
|
973
1005
|
</div>
|
|
974
1006
|
`
|
|
1007
|
+
: sourceType === "timeseries-json"
|
|
1008
|
+
? html`
|
|
1009
|
+
<div class="form-group">
|
|
1010
|
+
<label class="form-label">Timeseries JSON URL</label>
|
|
1011
|
+
<input
|
|
1012
|
+
class="form-input"
|
|
1013
|
+
type="text"
|
|
1014
|
+
placeholder="https://example.com/data.json"
|
|
1015
|
+
.value=${this.formData.credentials?.url || ""}
|
|
1016
|
+
@input=${(e: Event) =>
|
|
1017
|
+
this.updateForm("url", (e.target as HTMLInputElement).value)}
|
|
1018
|
+
/>
|
|
1019
|
+
<small style="color: var(--text-muted, rgba(255, 255, 255, 0.5)); font-size: 11px;">
|
|
1020
|
+
Expected format: JSON array of objects with a <code>timestamp</code> field. Rendered as a heatmap backdrop.
|
|
1021
|
+
</small>
|
|
1022
|
+
</div>
|
|
1023
|
+
`
|
|
975
1024
|
: sourceType === "google"
|
|
976
1025
|
? html`
|
|
977
1026
|
<div class="google-auth-section">
|
package/src/CalDAVSource.ts
CHANGED
|
@@ -74,13 +74,18 @@ export class CalDAVSource implements CalendarSource {
|
|
|
74
74
|
* Make an authenticated request to the CalDAV server.
|
|
75
75
|
*/
|
|
76
76
|
async request(url: string, method: string, body?: string, headers: Record<string, string> = {}): Promise<Response> {
|
|
77
|
+
const requestHeaders: Record<string, string> = {
|
|
78
|
+
'Authorization': this.getAuthHeader(),
|
|
79
|
+
...headers,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
if (body && !Object.keys(requestHeaders).some((key) => key.toLowerCase() === 'content-type')) {
|
|
83
|
+
requestHeaders['Content-Type'] = 'application/xml; charset=utf-8';
|
|
84
|
+
}
|
|
85
|
+
|
|
77
86
|
const response = await fetch(url, {
|
|
78
87
|
method,
|
|
79
|
-
headers:
|
|
80
|
-
'Authorization': this.getAuthHeader(),
|
|
81
|
-
'Content-Type': 'application/xml; charset=utf-8',
|
|
82
|
-
...headers,
|
|
83
|
-
},
|
|
88
|
+
headers: requestHeaders,
|
|
84
89
|
body,
|
|
85
90
|
});
|
|
86
91
|
|
|
@@ -242,7 +247,7 @@ export class CalDAVSource implements CalendarSource {
|
|
|
242
247
|
/**
|
|
243
248
|
* Fetch calendar objects (events) from a specific calendar.
|
|
244
249
|
*/
|
|
245
|
-
private async fetchCalendarObjects(calendarUrl: string): Promise<string
|
|
250
|
+
private async fetchCalendarObjects(calendarUrl: string): Promise<Array<{ href: string; icalData: string }>> {
|
|
246
251
|
const response = await this.request(
|
|
247
252
|
calendarUrl,
|
|
248
253
|
'REPORT',
|
|
@@ -263,11 +268,17 @@ export class CalDAVSource implements CalendarSource {
|
|
|
263
268
|
|
|
264
269
|
const text = await response.text();
|
|
265
270
|
|
|
266
|
-
const
|
|
267
|
-
const
|
|
271
|
+
const calendarObjects: Array<{ href: string; icalData: string }> = [];
|
|
272
|
+
const responseRegex = /<[^:]+:response[^>]*>([\s\S]*?)<\/[^:]+:response>/g;
|
|
273
|
+
|
|
274
|
+
for (const match of text.matchAll(responseRegex)) {
|
|
275
|
+
const responseBlock = match[1];
|
|
276
|
+
const hrefMatch = responseBlock.match(/<[^:]+:href[^>]*>(.*?)<\/[^:]+:href>/);
|
|
277
|
+
const calendarDataMatch = responseBlock.match(/<[^:]+:calendar-data[^>]*>([\s\S]*?)<\/[^:]+:calendar-data>/);
|
|
278
|
+
if (!hrefMatch || !calendarDataMatch) continue;
|
|
268
279
|
|
|
269
|
-
|
|
270
|
-
let icalData =
|
|
280
|
+
const href = new URL(hrefMatch[1], this.credentials.serverUrl).toString();
|
|
281
|
+
let icalData = calendarDataMatch[1].trim();
|
|
271
282
|
// Unescape XML entities
|
|
272
283
|
icalData = icalData
|
|
273
284
|
.replace(/</g, '<')
|
|
@@ -277,11 +288,11 @@ export class CalDAVSource implements CalendarSource {
|
|
|
277
288
|
.replace(/'/g, "'");
|
|
278
289
|
|
|
279
290
|
if (icalData) {
|
|
280
|
-
|
|
291
|
+
calendarObjects.push({ href, icalData });
|
|
281
292
|
}
|
|
282
293
|
}
|
|
283
294
|
|
|
284
|
-
return
|
|
295
|
+
return calendarObjects;
|
|
285
296
|
}
|
|
286
297
|
|
|
287
298
|
|
|
@@ -366,7 +377,13 @@ export class CalDAVSource implements CalendarSource {
|
|
|
366
377
|
/**
|
|
367
378
|
* Map a parsed VCALENDAR event to CalendarEvent.
|
|
368
379
|
*/
|
|
369
|
-
private mapToCalendarEvent(
|
|
380
|
+
private mapToCalendarEvent(
|
|
381
|
+
parsed: Partial<CalendarEvent>,
|
|
382
|
+
calendarDisplayName: string,
|
|
383
|
+
calendarUrl: string,
|
|
384
|
+
calendarColor?: string,
|
|
385
|
+
resourceUrl?: string
|
|
386
|
+
): CalendarEvent | null {
|
|
370
387
|
if (!parsed.start || !parsed.id) return null;
|
|
371
388
|
|
|
372
389
|
return {
|
|
@@ -387,6 +404,7 @@ export class CalDAVSource implements CalendarSource {
|
|
|
387
404
|
readOnly: false,
|
|
388
405
|
isAllDay: parsed.isAllDay,
|
|
389
406
|
reminders: parsed.reminders,
|
|
407
|
+
resourceUrl,
|
|
390
408
|
};
|
|
391
409
|
}
|
|
392
410
|
|
|
@@ -395,9 +413,21 @@ export class CalDAVSource implements CalendarSource {
|
|
|
395
413
|
*/
|
|
396
414
|
async fetchEventsForCalendar(calendarUrl: string, displayName: string, color?: string): Promise<CalendarEvent[]> {
|
|
397
415
|
const calendarObjects = await this.fetchCalendarObjects(calendarUrl);
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
416
|
+
const events: CalendarEvent[] = [];
|
|
417
|
+
|
|
418
|
+
for (const calendarObject of calendarObjects) {
|
|
419
|
+
const event = this.mapToCalendarEvent(
|
|
420
|
+
parseSingleICalEvent(calendarObject.icalData),
|
|
421
|
+
displayName,
|
|
422
|
+
calendarUrl,
|
|
423
|
+
color,
|
|
424
|
+
calendarObject.href
|
|
425
|
+
);
|
|
426
|
+
if (!event) continue;
|
|
427
|
+
events.push(event);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return events;
|
|
401
431
|
}
|
|
402
432
|
|
|
403
433
|
/**
|
|
@@ -440,6 +470,7 @@ export class CalDAVSource implements CalendarSource {
|
|
|
440
470
|
await this.request(eventUrl, 'PUT', icalData, {
|
|
441
471
|
'Content-Type': 'text/calendar; charset=utf-8',
|
|
442
472
|
});
|
|
473
|
+
fullEvent.resourceUrl = eventUrl;
|
|
443
474
|
|
|
444
475
|
return fullEvent;
|
|
445
476
|
}
|
|
@@ -448,10 +479,11 @@ export class CalDAVSource implements CalendarSource {
|
|
|
448
479
|
* Update an existing event on the CalDAV server.
|
|
449
480
|
* Derives the calendar URL from updates.calendarId, falling back to the event cache.
|
|
450
481
|
*/
|
|
451
|
-
async updateEvent(
|
|
452
|
-
const
|
|
482
|
+
async updateEvent(event: CalendarEvent, updates: Partial<CalendarEvent>): Promise<CalendarEvent> {
|
|
483
|
+
const id = event.id;
|
|
484
|
+
const calendarUrl = updates.calendarId ?? event.calendarId;
|
|
453
485
|
if (!calendarUrl) throw new Error(`Cannot update event ${id}: calendar URL unknown`);
|
|
454
|
-
const eventUrl = `${calendarUrl.replace(/\/$/, '')}/${id}.ics`;
|
|
486
|
+
const eventUrl = updates.resourceUrl ?? event.resourceUrl ?? `${calendarUrl.replace(/\/$/, '')}/${id}.ics`;
|
|
455
487
|
|
|
456
488
|
// For updates, we need to fetch the existing event first
|
|
457
489
|
// This is a simplified implementation
|
|
@@ -480,6 +512,7 @@ export class CalDAVSource implements CalendarSource {
|
|
|
480
512
|
readOnly: false,
|
|
481
513
|
isAllDay: updates.isAllDay !== undefined ? updates.isAllDay : existing.isAllDay,
|
|
482
514
|
reminders: updates.reminders !== undefined ? updates.reminders : existing.reminders,
|
|
515
|
+
resourceUrl: updates.resourceUrl ?? eventUrl,
|
|
483
516
|
};
|
|
484
517
|
|
|
485
518
|
const icalData = this.serializeEventToICal(updatedEvent);
|
|
@@ -11,7 +11,7 @@ export interface Calendar {
|
|
|
11
11
|
calendarUrl?: string;
|
|
12
12
|
fetchEvents(): Promise<CalendarEvent[]>;
|
|
13
13
|
createEvent?(event: Omit<CalendarEvent, 'calendar' | 'color'>): Promise<CalendarEvent>;
|
|
14
|
-
updateEvent?(
|
|
14
|
+
updateEvent?(event: CalendarEvent, updates: Partial<CalendarEvent>): Promise<CalendarEvent>;
|
|
15
15
|
deleteEvent?(id: string): Promise<void>;
|
|
16
16
|
}
|
|
17
17
|
|
|
@@ -28,7 +28,7 @@ export interface CalendarSource {
|
|
|
28
28
|
enabled: boolean;
|
|
29
29
|
fetchEvents(): Promise<CalendarEvent[]>;
|
|
30
30
|
createEvent?(event: Omit<CalendarEvent, 'calendar' | 'color'>): Promise<CalendarEvent>;
|
|
31
|
-
updateEvent?(
|
|
31
|
+
updateEvent?(event: CalendarEvent, updates: Partial<CalendarEvent>): Promise<CalendarEvent>;
|
|
32
32
|
deleteEvent?(id: string): Promise<void>;
|
|
33
33
|
}
|
|
34
34
|
|
package/src/CalendarInternal.ts
CHANGED
|
@@ -49,6 +49,8 @@ export interface CalendarEvent {
|
|
|
49
49
|
status?: 'TENTATIVE' | 'CONFIRMED' | 'CANCELLED';
|
|
50
50
|
reminders?: NotificationConfig[];
|
|
51
51
|
isAllDay?: boolean;
|
|
52
|
+
visualStyle?: 'heatmap';
|
|
53
|
+
resourceUrl?: string;
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
export interface WeekInfo {
|
|
@@ -72,8 +74,9 @@ export interface EventSegment {
|
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
// Configuration for the sliding window buffer
|
|
75
|
-
const BUFFER_WEEKS =
|
|
76
|
-
const EXTEND_WEEKS =
|
|
77
|
+
const BUFFER_WEEKS = 4; // Trigger extension when within this many weeks of the edge
|
|
78
|
+
const EXTEND_WEEKS = 26; // Weeks to add per extension (~6 months); must be >> BUFFER_WEEKS so
|
|
79
|
+
// the scroll thumb lands well past the buffer entry after each extension, preventing cascade
|
|
77
80
|
const MAX_WEEKS = 96; // Maximum weeks to keep in memory (trim beyond this)
|
|
78
81
|
|
|
79
82
|
export class CalendarInternal {
|
|
@@ -258,18 +261,21 @@ export class CalendarInternal {
|
|
|
258
261
|
|
|
259
262
|
getFilteredEvents(filter?: string) {
|
|
260
263
|
const baseEvents = Array.from(this.calendarEvents.values());
|
|
264
|
+
return this.filterEvents(baseEvents, filter);
|
|
265
|
+
}
|
|
261
266
|
|
|
267
|
+
filterEvents(events: CalendarEvent[], filter?: string): CalendarEvent[] {
|
|
262
268
|
// Filter by enabled calendars
|
|
263
269
|
// Note: enabledCalendars contains calendar IDs, not sourceIds
|
|
264
270
|
const enabledEvents = this.enabledCalendars.size > 0
|
|
265
|
-
?
|
|
271
|
+
? events.filter(e => {
|
|
266
272
|
// For backwards compatibility: check both sourceId and calendarId
|
|
267
273
|
// CalDAV events: match via calendarId
|
|
268
274
|
// Other sources: match via sourceId
|
|
269
275
|
return (e.calendarId && this.enabledCalendars.has(e.calendarId)) ||
|
|
270
276
|
(e.sourceId && this.enabledCalendars.has(e.sourceId));
|
|
271
277
|
})
|
|
272
|
-
:
|
|
278
|
+
: events;
|
|
273
279
|
|
|
274
280
|
// Mark events from locked calendars as read-only
|
|
275
281
|
const lockedEvents = this.lockedCalendars.size > 0
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { WeekInfo } from "./CalendarInternal.js";
|
|
2
|
+
|
|
3
|
+
// Minimum dayHeight at which timed events are rendered at their actual time-of-day position.
|
|
4
|
+
export const TIME_SCALE_DAY_HEIGHT = 300;
|
|
5
|
+
|
|
6
|
+
export interface LayerContext {
|
|
7
|
+
ctx: CanvasRenderingContext2D;
|
|
8
|
+
width: number;
|
|
9
|
+
height: number;
|
|
10
|
+
scrollTop: number;
|
|
11
|
+
dayWidth: number;
|
|
12
|
+
dayHeight: number;
|
|
13
|
+
leftGutterWidth: number;
|
|
14
|
+
columnsPerRow: number;
|
|
15
|
+
rowsPerWeek: number;
|
|
16
|
+
visibleWeeks: WeekInfo[];
|
|
17
|
+
allWeeks: WeekInfo[];
|
|
18
|
+
fontFamily: string;
|
|
19
|
+
styles: Record<string, string>;
|
|
20
|
+
getDayVisualPosition: (dayIndex: number) => { row: number; col: number };
|
|
21
|
+
filter: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CalendarLayer {
|
|
25
|
+
name: string;
|
|
26
|
+
enabled: boolean;
|
|
27
|
+
render(lc: LayerContext): void;
|
|
28
|
+
}
|