@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/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@luckydye/calendar",
3
- "version": "1.3.0",
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/calendar.ts",
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",
@@ -44,7 +44,20 @@ interface InhouseSource extends CalendarSource {
44
44
  locked?: boolean;
45
45
  }
46
46
 
47
- type ConfigurableSource = CalDAVSourceConfig | ICalSource | GoogleSource | InhouseSource;
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">
@@ -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 calendarData: string[] = [];
267
- const calendarDataRegex = /<[^:]+:calendar-data[^>]*>(.*?)<\/[^:]+:calendar-data>/gs;
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
- for (const match of text.matchAll(calendarDataRegex)) {
270
- let icalData = match[1].trim();
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(/&lt;/g, '<')
@@ -277,11 +288,11 @@ export class CalDAVSource implements CalendarSource {
277
288
  .replace(/&apos;/g, "'");
278
289
 
279
290
  if (icalData) {
280
- calendarData.push(icalData);
291
+ calendarObjects.push({ href, icalData });
281
292
  }
282
293
  }
283
294
 
284
- return calendarData;
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(parsed: Partial<CalendarEvent>, calendarDisplayName: string, calendarUrl: string, calendarColor?: string): CalendarEvent | null {
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
- return calendarObjects
399
- .map(ical => this.mapToCalendarEvent(parseSingleICalEvent(ical), displayName, calendarUrl, color))
400
- .filter((e): e is CalendarEvent => e !== null);
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(id: string, updates: Partial<CalendarEvent>): Promise<CalendarEvent> {
452
- const calendarUrl = updates.calendarId ?? this.eventCalendarMap.get(id);
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?(id: string, updates: Partial<CalendarEvent>): Promise<CalendarEvent>;
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?(id: string, updates: Partial<CalendarEvent>): Promise<CalendarEvent>;
31
+ updateEvent?(event: CalendarEvent, updates: Partial<CalendarEvent>): Promise<CalendarEvent>;
32
32
  deleteEvent?(id: string): Promise<void>;
33
33
  }
34
34
 
@@ -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 = 12; // Weeks to keep as buffer outside viewport
76
- const EXTEND_WEEKS = 12; // Weeks to add when extending
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
- ? baseEvents.filter(e => {
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
- : baseEvents;
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
+ }