@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.
@@ -0,0 +1,958 @@
1
+ import type {
2
+ CalendarEvent,
3
+ EventSegment,
4
+ WeekInfo,
5
+ } from "../CalendarInternal.js";
6
+ import { TIME_SCALE_DAY_HEIGHT, type CalendarLayer, type LayerContext } from "../CalendarLayer.js";
7
+ import { hexToRgb, rgbToHsl } from "../Color.js";
8
+
9
+ const MIN_EVENT_HEIGHT = 20;
10
+
11
+ export interface EventRect {
12
+ event: CalendarEvent;
13
+ x: number;
14
+ y: number;
15
+ width: number;
16
+ height: number;
17
+ }
18
+
19
+ export interface EventsState {
20
+ events: CalendarEvent[];
21
+ hoveredEventId: string | null;
22
+ isEventSelected: (event: CalendarEvent) => boolean;
23
+ shouldRenderEventWithStripes: (event: CalendarEvent) => boolean;
24
+ getStripePatternCanvas: () => HTMLCanvasElement | null;
25
+ }
26
+
27
+ export function createEventsLayer(
28
+ state: EventsState,
29
+ ): CalendarLayer & { eventRects: EventRect[] } {
30
+ const layer = {
31
+ name: "events",
32
+ enabled: true,
33
+ eventRects: [] as EventRect[],
34
+ render(lc: LayerContext): void {
35
+ const {
36
+ ctx,
37
+ width,
38
+ height,
39
+ scrollTop,
40
+ dayWidth,
41
+ dayHeight,
42
+ leftGutterWidth,
43
+ visibleWeeks,
44
+ allWeeks,
45
+ fontFamily,
46
+ styles,
47
+ getDayVisualPosition,
48
+ } = lc;
49
+
50
+ const events = state.events;
51
+ const viewportBottom = scrollTop + height;
52
+ const showTimeScale = dayHeight >= TIME_SCALE_DAY_HEIGHT;
53
+
54
+ layer.eventRects = [];
55
+
56
+ if (visibleWeeks.length === 0) return;
57
+
58
+ const firstVisibleWeek = visibleWeeks[0]!;
59
+ const lastVisibleWeek =
60
+ visibleWeeks[visibleWeeks.length - 1]!;
61
+ const firstVisibleDay = firstVisibleWeek.days[0]!;
62
+ const lastVisibleDay = lastVisibleWeek.days[6]!;
63
+ const visibleStartTime = firstVisibleDay.getTime();
64
+ const visibleEndTime =
65
+ lastVisibleDay.getTime() + 86400000 - 1;
66
+
67
+ const dayOccupiedRows = new Map<string, Set<number>>();
68
+ const eventRowIndex = new Map<string, number>();
69
+
70
+ const segments: EventSegment[] = [];
71
+ const allDaySegments: EventSegment[] = [];
72
+ const timedSegments: EventSegment[] = [];
73
+
74
+ for (const event of events) {
75
+ if (event.visualStyle === "heatmap") continue;
76
+ const eventStartTime = event.start.getTime();
77
+ const eventEndTime = event.end.getTime();
78
+
79
+ if (
80
+ eventEndTime < visibleStartTime ||
81
+ eventStartTime > visibleEndTime
82
+ )
83
+ continue;
84
+
85
+ const eventWeeks: {
86
+ weekIndex: number;
87
+ week: WeekInfo;
88
+ }[] = [];
89
+
90
+ for (const week of visibleWeeks) {
91
+ const weekStartDay = week.days[0];
92
+ const weekEndDay = week.days[6];
93
+ if (!weekStartDay || !weekEndDay) continue;
94
+
95
+ const weekStart = weekStartDay.getTime();
96
+ const weekEnd = weekEndDay.getTime() + 86399999;
97
+
98
+ if (
99
+ eventEndTime >= weekStart &&
100
+ eventStartTime <= weekEnd
101
+ ) {
102
+ const weekIndex = allWeeks.indexOf(week);
103
+ eventWeeks.push({ weekIndex, week });
104
+ }
105
+ }
106
+
107
+ const totalWeeks = eventWeeks.length;
108
+ const isAllDay = event.isAllDay === true;
109
+
110
+ for (let i = 0; i < eventWeeks.length; i++) {
111
+ const { weekIndex, week } = eventWeeks[i]!;
112
+
113
+ const isStart = i === 0;
114
+ const isEnd = i === eventWeeks.length - 1;
115
+
116
+ let startDayIndex = 0;
117
+ let endDayIndex = 6;
118
+
119
+ if (isStart) {
120
+ startDayIndex = getDayIndexInWeek(
121
+ week,
122
+ event.start,
123
+ );
124
+ }
125
+ if (isEnd) {
126
+ const effectiveEnd = isAllDay
127
+ ? new Date(event.end.getTime() - 1)
128
+ : event.end;
129
+ endDayIndex = getDayIndexInWeek(
130
+ week,
131
+ effectiveEnd,
132
+ );
133
+ }
134
+
135
+ const segment = {
136
+ event,
137
+ weekIndex,
138
+ week,
139
+ startDayIndex,
140
+ endDayIndex,
141
+ isStart,
142
+ isEnd,
143
+ totalWeeks,
144
+ };
145
+
146
+ if (isAllDay) {
147
+ allDaySegments.push(segment);
148
+ } else {
149
+ timedSegments.push(segment);
150
+ }
151
+ }
152
+ }
153
+
154
+ const renderedTimedSegments = showTimeScale
155
+ ? timedSegments.flatMap((seg) => {
156
+ if (seg.startDayIndex === seg.endDayIndex)
157
+ return [seg];
158
+ const result = [];
159
+ for (
160
+ let d = seg.startDayIndex;
161
+ d <= seg.endDayIndex;
162
+ d++
163
+ ) {
164
+ result.push({
165
+ ...seg,
166
+ startDayIndex: d,
167
+ endDayIndex: d,
168
+ isStart:
169
+ d === seg.startDayIndex && seg.isStart,
170
+ isEnd: d === seg.endDayIndex && seg.isEnd,
171
+ totalWeeks: 1,
172
+ });
173
+ }
174
+ return result;
175
+ })
176
+ : timedSegments;
177
+
178
+ segments.push(...allDaySegments, ...renderedTimedSegments);
179
+
180
+ // Calculate columns for overlapping events in time scale mode
181
+ interface EventLayout {
182
+ segment: EventSegment;
183
+ yStart: number;
184
+ yEnd: number;
185
+ column: number;
186
+ totalColumns: number;
187
+ }
188
+
189
+ const segmentsByWeekDay = new Map<
190
+ string,
191
+ EventSegment[]
192
+ >();
193
+ for (const segment of segments) {
194
+ const allDay = segment.event.isAllDay === true;
195
+ if (showTimeScale && !allDay) {
196
+ for (
197
+ let dayIdx = segment.startDayIndex;
198
+ dayIdx <= segment.endDayIndex;
199
+ dayIdx++
200
+ ) {
201
+ const key = `${segment.weekIndex}-${dayIdx}`;
202
+ if (!segmentsByWeekDay.has(key)) {
203
+ segmentsByWeekDay.set(key, []);
204
+ }
205
+ segmentsByWeekDay.get(key)!.push(segment);
206
+ }
207
+ }
208
+ }
209
+
210
+ const segmentColumns = new Map<
211
+ string,
212
+ { column: number; totalColumns: number }
213
+ >();
214
+
215
+ for (const [dayKey, daySegments] of segmentsByWeekDay) {
216
+ const [weekIndexStr, dayIndexStr] = dayKey.split("-");
217
+ const dayIndex = parseInt(dayIndexStr!);
218
+ const weekIndex = parseInt(weekIndexStr!);
219
+
220
+ interface SegmentTime {
221
+ segment: EventSegment;
222
+ startMinutes: number;
223
+ endMinutes: number;
224
+ }
225
+ const segmentTimes: SegmentTime[] = [];
226
+
227
+ for (const seg of daySegments) {
228
+ const week = allWeeks[weekIndex];
229
+ if (!week) continue;
230
+
231
+ const dayStartTime = new Date(
232
+ week.days[dayIndex]!,
233
+ ).setHours(0, 0, 0, 0);
234
+ const dayEndTime = new Date(
235
+ week.days[dayIndex]!,
236
+ ).setHours(23, 59, 59, 999);
237
+ const eventStartTime = seg.event.start.getTime();
238
+ const eventEndTime =
239
+ seg.event.end.getTime() - 60000;
240
+
241
+ const effectiveStartTime = Math.max(
242
+ eventStartTime,
243
+ dayStartTime,
244
+ );
245
+ const effectiveEndTime = Math.min(
246
+ eventEndTime,
247
+ dayEndTime,
248
+ );
249
+
250
+ const effectiveStart = new Date(effectiveStartTime);
251
+ const effectiveEnd = new Date(effectiveEndTime);
252
+
253
+ const startMinutes =
254
+ effectiveStart.getHours() * 60 +
255
+ effectiveStart.getMinutes();
256
+ const endMinutes =
257
+ effectiveEnd.getHours() * 60 +
258
+ effectiveEnd.getMinutes();
259
+
260
+ segmentTimes.push({
261
+ segment: seg,
262
+ startMinutes,
263
+ endMinutes,
264
+ });
265
+ }
266
+
267
+ segmentTimes.sort((a, b) => {
268
+ if (a.startMinutes !== b.startMinutes)
269
+ return a.startMinutes - b.startMinutes;
270
+ return (
271
+ b.endMinutes -
272
+ b.startMinutes -
273
+ (a.endMinutes - a.startMinutes)
274
+ );
275
+ });
276
+
277
+ const columns: { endMinutes: number }[] = [];
278
+
279
+ for (const st of segmentTimes) {
280
+ let columnIndex = 0;
281
+ for (
282
+ ;
283
+ columnIndex < columns.length;
284
+ columnIndex++
285
+ ) {
286
+ if (
287
+ columns[columnIndex]!.endMinutes <=
288
+ st.startMinutes
289
+ ) {
290
+ break;
291
+ }
292
+ }
293
+
294
+ if (columnIndex === columns.length) {
295
+ columns.push({ endMinutes: st.endMinutes });
296
+ } else {
297
+ columns[columnIndex]!.endMinutes =
298
+ st.endMinutes;
299
+ }
300
+
301
+ const segmentKey = `${st.segment.weekIndex}-${st.segment.event.id}-${dayIndex}`;
302
+ segmentColumns.set(segmentKey, {
303
+ column: columnIndex,
304
+ totalColumns: columns.length,
305
+ });
306
+ }
307
+
308
+ for (const st of segmentTimes) {
309
+ const segmentKey = `${st.segment.weekIndex}-${st.segment.event.id}-${dayIndex}`;
310
+ const layout = segmentColumns.get(segmentKey);
311
+ if (layout) {
312
+ layout.totalColumns = columns.length;
313
+ }
314
+ }
315
+ }
316
+
317
+ // Render segments
318
+ for (const segment of segments) {
319
+ const {
320
+ event,
321
+ week,
322
+ weekIndex,
323
+ startDayIndex,
324
+ endDayIndex,
325
+ isStart,
326
+ isEnd,
327
+ totalWeeks,
328
+ } = segment;
329
+ const weekYOffset = week.yOffset;
330
+ const allDay = event.isAllDay === true;
331
+
332
+ const startVisualPos =
333
+ getDayVisualPosition(startDayIndex);
334
+ const endVisualPos = getDayVisualPosition(endDayIndex);
335
+
336
+ let yStart: number;
337
+ let yEnd: number;
338
+
339
+ if (showTimeScale && !allDay) {
340
+ const dayStartTime = new Date(
341
+ week.days[startDayIndex]!,
342
+ ).setHours(0, 0, 0, 0);
343
+ const dayEndTime = new Date(
344
+ week.days[endDayIndex]!,
345
+ ).setHours(23, 59, 59, 999);
346
+ const eventStartTime = event.start.getTime();
347
+ const eventEndTime =
348
+ event.end.getTime() - 60000;
349
+
350
+ const effectiveStartTime = Math.max(
351
+ eventStartTime,
352
+ dayStartTime,
353
+ );
354
+ const effectiveEndTime = Math.min(
355
+ eventEndTime,
356
+ dayEndTime,
357
+ );
358
+
359
+ const effectiveStart = new Date(
360
+ effectiveStartTime,
361
+ );
362
+ const effectiveEnd = new Date(effectiveEndTime);
363
+
364
+ const startMinutes =
365
+ effectiveStart.getHours() * 60 +
366
+ effectiveStart.getMinutes();
367
+ const endMinutes =
368
+ effectiveEnd.getHours() * 60 +
369
+ effectiveEnd.getMinutes();
370
+
371
+ const visualRowY =
372
+ weekYOffset +
373
+ startVisualPos.row * dayHeight;
374
+ yStart =
375
+ visualRowY +
376
+ (startMinutes / 1440) * dayHeight;
377
+ yEnd =
378
+ visualRowY +
379
+ (endMinutes / 1440) * dayHeight;
380
+ } else {
381
+ const eventKey = `${weekIndex}-${event.id}`;
382
+ let rowIndex = eventRowIndex.get(eventKey);
383
+
384
+ if (rowIndex === undefined) {
385
+ rowIndex = 0;
386
+ while (true) {
387
+ let rowFree = true;
388
+ for (
389
+ let d = startDayIndex;
390
+ d <= endDayIndex;
391
+ d++
392
+ ) {
393
+ const dayKey = `${weekIndex}-${d}`;
394
+ const occupied =
395
+ dayOccupiedRows.get(dayKey);
396
+ if (occupied?.has(rowIndex)) {
397
+ rowFree = false;
398
+ break;
399
+ }
400
+ }
401
+ if (rowFree) break;
402
+ rowIndex++;
403
+ }
404
+ eventRowIndex.set(eventKey, rowIndex);
405
+ }
406
+
407
+ for (
408
+ let d = startDayIndex;
409
+ d <= endDayIndex;
410
+ d++
411
+ ) {
412
+ const dayKey = `${weekIndex}-${d}`;
413
+ let occupied = dayOccupiedRows.get(dayKey);
414
+ if (!occupied) {
415
+ occupied = new Set();
416
+ dayOccupiedRows.set(dayKey, occupied);
417
+ }
418
+ occupied.add(rowIndex);
419
+ }
420
+
421
+ const maxEventsInRow = Math.floor(
422
+ (dayHeight - 4) / (MIN_EVENT_HEIGHT + 2),
423
+ );
424
+ if (rowIndex >= maxEventsInRow) continue;
425
+
426
+ const visualRowY =
427
+ weekYOffset +
428
+ startVisualPos.row * dayHeight;
429
+ yStart =
430
+ visualRowY +
431
+ 4 +
432
+ rowIndex * (MIN_EVENT_HEIGHT + 2);
433
+ yEnd = yStart + MIN_EVENT_HEIGHT;
434
+ }
435
+
436
+ const eventHeight = Math.max(
437
+ showTimeScale ? 4 : MIN_EVENT_HEIGHT,
438
+ yEnd - yStart,
439
+ );
440
+
441
+ let x: number;
442
+ let spanWidth: number;
443
+
444
+ const segmentKey = `${weekIndex}-${event.id}-${startDayIndex}`;
445
+ const columnLayout = segmentColumns.get(segmentKey);
446
+
447
+ if (
448
+ showTimeScale &&
449
+ !allDay &&
450
+ columnLayout &&
451
+ columnLayout.totalColumns > 1
452
+ ) {
453
+ const columnWidth =
454
+ dayWidth / columnLayout.totalColumns;
455
+ x =
456
+ leftGutterWidth +
457
+ startVisualPos.col * dayWidth +
458
+ columnLayout.column * columnWidth;
459
+ spanWidth = columnWidth;
460
+ } else {
461
+ const colSpan =
462
+ endVisualPos.col - startVisualPos.col + 1;
463
+ x =
464
+ leftGutterWidth +
465
+ startVisualPos.col * dayWidth;
466
+ spanWidth = colSpan * dayWidth;
467
+ }
468
+
469
+ const viewportY = yStart - scrollTop;
470
+
471
+ if (
472
+ viewportY + eventHeight < 0 ||
473
+ viewportY > height
474
+ )
475
+ continue;
476
+
477
+ const eventColor = event.color || "#888888";
478
+ const isSelected = state.isEventSelected(event);
479
+ const isHovered =
480
+ state.hoveredEventId === event.id;
481
+
482
+ const padding = 2;
483
+ const eventWidth = spanWidth - padding * 2;
484
+ const leftBorderWidth = 3;
485
+ const contentPadding = 6;
486
+
487
+ let radiusTopLeft = 4;
488
+ let radiusTopRight = 4;
489
+ let radiusBottomLeft = 4;
490
+ let radiusBottomRight = 4;
491
+
492
+ if (totalWeeks > 1) {
493
+ if (isStart && !isEnd) {
494
+ radiusTopRight = 0;
495
+ radiusBottomRight = 0;
496
+ } else if (!isStart && isEnd) {
497
+ radiusTopLeft = 0;
498
+ radiusBottomLeft = 0;
499
+ } else if (!isStart && !isEnd) {
500
+ radiusTopLeft = 0;
501
+ radiusTopRight = 0;
502
+ radiusBottomLeft = 0;
503
+ radiusBottomRight = 0;
504
+ }
505
+ }
506
+
507
+ ctx.save();
508
+
509
+ if (event.readOnly) {
510
+ ctx.globalAlpha = 0.5;
511
+ }
512
+
513
+ const hsl = rgbToHsl(hexToRgb(eventColor));
514
+ const textPrimary =
515
+ styles["--text-primary"] ||
516
+ "rgba(255, 255, 255, 0.9)";
517
+ const textInverse =
518
+ styles["--text-inverse"] || "rgb(0, 0, 0)";
519
+ const backgroundColor = isSelected
520
+ ? eventColor
521
+ : `hsla(${hsl[0]}, ${Math.min(hsl[1] * 0.9, 90)}%, ${Math.min(hsl[2] + 15, 40)}%, 0.45)`;
522
+ const borderColor = isSelected
523
+ ? eventColor
524
+ : `hsla(${hsl[0]}, ${Math.min(hsl[1] * 0.9, 90)}%, ${Math.min(hsl[2] + 10, 70)}%, 1)`;
525
+
526
+ const useStripes =
527
+ state.shouldRenderEventWithStripes(event);
528
+
529
+ ctx.beginPath();
530
+ ctx.roundRect(
531
+ x + padding,
532
+ viewportY,
533
+ eventWidth,
534
+ eventHeight,
535
+ [
536
+ radiusTopLeft,
537
+ radiusTopRight,
538
+ radiusBottomRight,
539
+ radiusBottomLeft,
540
+ ],
541
+ );
542
+
543
+ ctx.fillStyle = backgroundColor;
544
+ ctx.fill();
545
+
546
+ if (useStripes) {
547
+ const patternCanvas =
548
+ state.getStripePatternCanvas();
549
+ if (patternCanvas) {
550
+ ctx.save();
551
+ ctx.beginPath();
552
+ ctx.roundRect(
553
+ x + padding,
554
+ viewportY,
555
+ eventWidth,
556
+ eventHeight,
557
+ [
558
+ radiusTopLeft,
559
+ radiusTopRight,
560
+ radiusBottomRight,
561
+ radiusBottomLeft,
562
+ ],
563
+ );
564
+ ctx.clip();
565
+
566
+ const patternSize = 12;
567
+ const startX =
568
+ Math.floor((x + padding) / patternSize) *
569
+ patternSize;
570
+ const startY =
571
+ Math.floor(yStart / patternSize) *
572
+ patternSize -
573
+ scrollTop;
574
+
575
+ for (
576
+ let py = startY;
577
+ py < viewportY + eventHeight;
578
+ py += patternSize
579
+ ) {
580
+ for (
581
+ let px = startX;
582
+ px < x + padding + eventWidth;
583
+ px += patternSize
584
+ ) {
585
+ ctx.drawImage(
586
+ patternCanvas,
587
+ px,
588
+ py,
589
+ patternSize,
590
+ patternSize,
591
+ );
592
+ }
593
+ }
594
+
595
+ ctx.restore();
596
+ }
597
+ }
598
+
599
+ if (!isSelected) {
600
+ ctx.fillStyle = borderColor;
601
+ ctx.beginPath();
602
+ ctx.roundRect(
603
+ x + padding + 2,
604
+ viewportY + 2,
605
+ leftBorderWidth,
606
+ eventHeight - 4,
607
+ [
608
+ radiusTopLeft,
609
+ radiusTopLeft,
610
+ radiusBottomLeft,
611
+ radiusBottomLeft,
612
+ ],
613
+ );
614
+ ctx.fill();
615
+ }
616
+
617
+ if (isSelected) {
618
+ ctx.strokeStyle = textInverse;
619
+ ctx.lineWidth = 1;
620
+ ctx.beginPath();
621
+ ctx.roundRect(
622
+ x + padding,
623
+ viewportY,
624
+ eventWidth,
625
+ eventHeight,
626
+ [
627
+ radiusTopLeft,
628
+ radiusTopRight,
629
+ radiusBottomRight,
630
+ radiusBottomLeft,
631
+ ],
632
+ );
633
+ ctx.stroke();
634
+ }
635
+
636
+ if (isHovered && !isSelected) {
637
+ ctx.strokeStyle =
638
+ styles["--grid-color-hover"] ||
639
+ "rgba(255, 255, 255, 0.2)";
640
+ ctx.lineWidth = 1;
641
+ ctx.beginPath();
642
+ ctx.roundRect(
643
+ x + padding,
644
+ viewportY,
645
+ eventWidth,
646
+ eventHeight,
647
+ [
648
+ radiusTopLeft,
649
+ radiusTopRight,
650
+ radiusBottomRight,
651
+ radiusBottomLeft,
652
+ ],
653
+ );
654
+ ctx.stroke();
655
+ }
656
+
657
+ if (isStart && eventHeight >= 16) {
658
+ const textColor = isSelected
659
+ ? textInverse || "white"
660
+ : textPrimary || eventColor;
661
+ ctx.fillStyle = textColor;
662
+ ctx.font = `11px ${fontFamily}`;
663
+ ctx.textAlign = "left";
664
+ ctx.textBaseline = "top";
665
+
666
+ const textX =
667
+ x +
668
+ padding +
669
+ leftBorderWidth +
670
+ contentPadding +
671
+ 1;
672
+ const textY = viewportY + 6;
673
+ let maxTextWidth =
674
+ eventWidth -
675
+ leftBorderWidth -
676
+ contentPadding -
677
+ 4;
678
+
679
+ ctx.save();
680
+ ctx.beginPath();
681
+ ctx.rect(
682
+ x + padding + leftBorderWidth,
683
+ viewportY,
684
+ eventWidth - leftBorderWidth,
685
+ eventHeight,
686
+ );
687
+ ctx.clip();
688
+
689
+ if (event.rrule) {
690
+ ctx.font = `11px ${fontFamily}`;
691
+ const recurIcon = "⟳";
692
+ const iconWidth =
693
+ ctx.measureText(recurIcon).width;
694
+ ctx.fillText(recurIcon, textX, textY);
695
+ maxTextWidth -= iconWidth + 4;
696
+ }
697
+
698
+ const titleStartX =
699
+ textX +
700
+ (event.rrule
701
+ ? ctx.measureText("⟳").width + 4
702
+ : 0);
703
+ let displayTitle = event.title;
704
+ ctx.font = `11px ${fontFamily}`;
705
+ const titleWidth =
706
+ ctx.measureText(displayTitle).width;
707
+
708
+ if (titleWidth > maxTextWidth) {
709
+ const ellipsis = "…";
710
+ const ellipsisWidth =
711
+ ctx.measureText(ellipsis).width;
712
+
713
+ let left = 0;
714
+ let right = displayTitle.length;
715
+ let bestFit = 0;
716
+
717
+ while (left <= right) {
718
+ const mid = Math.floor(
719
+ (left + right) / 2,
720
+ );
721
+ const testText = displayTitle.substring(
722
+ 0,
723
+ mid,
724
+ );
725
+ const testWidth =
726
+ ctx.measureText(testText).width +
727
+ ellipsisWidth;
728
+
729
+ if (testWidth <= maxTextWidth) {
730
+ bestFit = mid;
731
+ left = mid + 1;
732
+ } else {
733
+ right = mid - 1;
734
+ }
735
+ }
736
+
737
+ displayTitle =
738
+ displayTitle.substring(0, bestFit) +
739
+ ellipsis;
740
+ }
741
+
742
+ ctx.fillText(
743
+ displayTitle,
744
+ titleStartX,
745
+ textY,
746
+ );
747
+
748
+ if (eventHeight >= 32) {
749
+ const formatTime = (date: Date) => {
750
+ const hours = date.getHours();
751
+ const minutes = date.getMinutes();
752
+ const ampm =
753
+ hours >= 12 ? "PM" : "AM";
754
+ const displayHours =
755
+ hours % 12 || 12;
756
+ return `${displayHours}:${minutes.toString().padStart(2, "0")} ${ampm}`;
757
+ };
758
+
759
+ const startTime = formatTime(
760
+ event.start,
761
+ );
762
+ const endTime = formatTime(event.end);
763
+ const timeText = `${startTime} – ${endTime}`;
764
+
765
+ const timeTextColor = isSelected
766
+ ? textInverse || "white"
767
+ : textPrimary || eventColor;
768
+ ctx.fillStyle = timeTextColor;
769
+ ctx.font = `10px ${fontFamily}`;
770
+
771
+ let displayTime = timeText;
772
+ let timeWidth =
773
+ ctx.measureText(displayTime).width;
774
+
775
+ if (timeWidth > maxTextWidth) {
776
+ const ellipsis = "…";
777
+ const ellipsisWidth =
778
+ ctx.measureText(ellipsis).width;
779
+
780
+ let left = 0;
781
+ let right = displayTime.length;
782
+ let bestFit = 0;
783
+
784
+ while (left <= right) {
785
+ const mid = Math.floor(
786
+ (left + right) / 2,
787
+ );
788
+ const testText =
789
+ displayTime.substring(0, mid);
790
+ const testWidth =
791
+ ctx.measureText(testText)
792
+ .width + ellipsisWidth;
793
+
794
+ if (testWidth <= maxTextWidth) {
795
+ bestFit = mid;
796
+ left = mid + 1;
797
+ } else {
798
+ right = mid - 1;
799
+ }
800
+ }
801
+
802
+ displayTime =
803
+ displayTime.substring(0, bestFit) +
804
+ ellipsis;
805
+ }
806
+
807
+ ctx.fillText(
808
+ displayTime,
809
+ textX,
810
+ textY + 14,
811
+ );
812
+ }
813
+
814
+ ctx.restore();
815
+ }
816
+
817
+ ctx.restore();
818
+
819
+ layer.eventRects.push({
820
+ event,
821
+ x: x + padding,
822
+ y: yStart,
823
+ width: eventWidth,
824
+ height: eventHeight,
825
+ });
826
+ }
827
+
828
+ // Render month labels
829
+ const monthNames = [
830
+ "January",
831
+ "February",
832
+ "March",
833
+ "April",
834
+ "May",
835
+ "June",
836
+ "July",
837
+ "August",
838
+ "September",
839
+ "October",
840
+ "November",
841
+ "December",
842
+ ];
843
+
844
+ const monthBoundaries: {
845
+ monthKey: string;
846
+ monthName: string;
847
+ year: number;
848
+ yOffset: number;
849
+ }[] = [];
850
+ const seenMonths = new Set<string>();
851
+
852
+ for (const week of visibleWeeks) {
853
+ const firstDay = week.days[0];
854
+ if (!firstDay) continue;
855
+
856
+ const monthIndex = firstDay.getMonth();
857
+ const year = firstDay.getFullYear();
858
+ const monthKey = `${monthIndex}-${year}`;
859
+
860
+ if (!seenMonths.has(monthKey)) {
861
+ seenMonths.add(monthKey);
862
+ const monthName = monthNames[monthIndex];
863
+ if (monthName) {
864
+ monthBoundaries.push({
865
+ monthKey,
866
+ monthName,
867
+ year,
868
+ yOffset: week.yOffset,
869
+ });
870
+ }
871
+ }
872
+ }
873
+
874
+ for (let i = 0; i < monthBoundaries.length; i++) {
875
+ const month = monthBoundaries[i]!;
876
+ const nextMonth = monthBoundaries[i + 1];
877
+ const labelY = month.yOffset;
878
+ const nextMonthY = nextMonth
879
+ ? nextMonth.yOffset
880
+ : Infinity;
881
+
882
+ if (nextMonthY < scrollTop) continue;
883
+ if (labelY > viewportBottom) break;
884
+
885
+ const labelPadding = [12, 0, 0, 12];
886
+
887
+ const stickyTop = Math.max(
888
+ 0,
889
+ scrollTop - labelY,
890
+ );
891
+ const maxStickyTop = nextMonthY - labelY - 24;
892
+ const clampedStickyTop = Math.min(
893
+ stickyTop,
894
+ maxStickyTop,
895
+ );
896
+ const labelTopMargin = 32;
897
+ const finalTop =
898
+ labelY +
899
+ clampedStickyTop -
900
+ scrollTop +
901
+ labelTopMargin;
902
+
903
+ ctx.save();
904
+ ctx.font = `bold 18px ${fontFamily}`;
905
+ ctx.textAlign = "left";
906
+ ctx.textBaseline = "top";
907
+
908
+ const labelText = `${month.monthName} ${month.year}`;
909
+ const textWidth =
910
+ ctx.measureText(labelText).width;
911
+ const leftMargin = 8;
912
+ const textX =
913
+ 64 + labelPadding[3] + leftMargin;
914
+ const textY = finalTop + labelPadding[0];
915
+
916
+ const bgPaddingLeft = 8;
917
+ const bgPaddingRight = 8;
918
+ const bgElevated =
919
+ styles["--bg-elevated"] ||
920
+ "rgba(0, 0, 0, 0.7)";
921
+ ctx.fillStyle = bgElevated;
922
+ ctx.beginPath();
923
+ ctx.roundRect(
924
+ textX - bgPaddingLeft,
925
+ textY - 4,
926
+ textWidth + bgPaddingLeft + bgPaddingRight,
927
+ 26,
928
+ 6,
929
+ );
930
+ ctx.fill();
931
+
932
+ const monthTextPrimary =
933
+ styles["--text-primary"] ||
934
+ "rgba(255, 255, 255, 0.95)";
935
+ ctx.fillStyle = monthTextPrimary;
936
+ ctx.fillText(labelText, textX, textY);
937
+ ctx.restore();
938
+ }
939
+ },
940
+ };
941
+ return layer;
942
+ }
943
+
944
+ function getDayIndexInWeek(week: WeekInfo, date: Date) {
945
+ const dateStart = new Date(date).setHours(0, 0, 0, 0);
946
+ for (let i = 0; i < 7; i++) {
947
+ const weekDay = week.days[i];
948
+ if (
949
+ weekDay &&
950
+ new Date(weekDay).setHours(0, 0, 0, 0) === dateStart
951
+ ) {
952
+ return i;
953
+ }
954
+ }
955
+ const weekStart = new Date(week.days[0]!).setHours(0, 0, 0, 0);
956
+ if (dateStart < weekStart) return 0;
957
+ return 6;
958
+ }