@luckydye/calendar 1.3.1 → 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.
@@ -1,32 +1,47 @@
1
- import { css, html, LitElement, render } from "lit";
1
+ import { LitElement, css, html, render } from "lit";
2
2
  import {
3
3
  type Attendee,
4
4
  type CalendarEvent,
5
5
  CalendarInternal,
6
- type WeekInfo,
7
6
  NOTIFICATION_PRESETS,
7
+ type WeekInfo,
8
8
  } from "./CalendarInternal.js";
9
+ import {
10
+ type CalendarLayer,
11
+ type LayerContext,
12
+ TIME_SCALE_DAY_HEIGHT,
13
+ } from "./CalendarLayer.js";
9
14
  import { hexToRgb, rgbToHsl } from "./Color.js";
15
+ import { sanitizeEventDescription } from "./DescriptionSanitizer.js";
16
+ import { serializeEventsToICal } from "./ICal.js";
17
+ import { IndexedDBStorage } from "./IndexedDBStorage.js";
18
+ import type { StatusBarData } from "./StatusBar.js";
19
+ import { queueStatus } from "./StatusMessage.js";
10
20
  import {
11
21
  type ThemeName,
12
22
  applyTheme,
13
- saveThemePreference,
14
- loadThemePreference,
15
23
  availableThemes,
24
+ loadThemePreference,
25
+ saveThemePreference,
16
26
  } from "./Theme.js";
17
- import { serializeEventsToICal } from "./ICal.js";
18
- import { queueStatus } from "./StatusMessage.js";
19
- import type { StatusBarData } from "./StatusBar.js";
20
- import { IndexedDBStorage } from "./IndexedDBStorage.js";
21
- import { TIME_SCALE_DAY_HEIGHT, type CalendarLayer, type LayerContext } from "./CalendarLayer.js";
27
+ import {
28
+ type EventRect,
29
+ type EventsState,
30
+ type PreviewEventData,
31
+ createEventsLayer,
32
+ } from "./layers/EventsLayer.js";
22
33
  import { createGridLayer } from "./layers/GridLayer.js";
23
- import { createEventsLayer, type EventRect, type EventsState } from "./layers/EventsLayer.js";
24
34
  import { createTimeseriesHeatmapLayer } from "./layers/TimeseriesHeatmapLayer.js";
25
35
 
26
36
  const MIN_DAY_HEIGHT = 50;
27
37
  const MAX_DAY_HEIGHT = 3000; // 1px per minute
28
38
  const LEFT_GUTTER_WIDTH = 60;
29
39
  const MINIMAP_WIDTH = 12;
40
+ const MIN_DAY_COLUMN_WIDTH = 80;
41
+ const MAX_DAY_COLUMN_WIDTH = 320;
42
+ const DEFAULT_DAY_COLUMN_WIDTH = 120;
43
+ const TIME_SCALE_EVENT_THRESHOLD = 400;
44
+ const FILTER_CONTEXT_WEEKS = 8;
30
45
 
31
46
  export class CalendarViewElement extends LitElement {
32
47
  static styles = css`
@@ -64,7 +79,7 @@ export class CalendarViewElement extends LitElement {
64
79
  bottom: 0;
65
80
  left: 0;
66
81
  right: 0;
67
- z-index: 100;
82
+ z-index: 200;
68
83
  }
69
84
 
70
85
  .toolbar::before {
@@ -159,6 +174,12 @@ export class CalendarViewElement extends LitElement {
159
174
  gap: 8px;
160
175
  }
161
176
 
177
+ .toolbar-slider-label {
178
+ font-size: 12px;
179
+ color: var(--text-muted, rgba(255, 255, 255, 0.6));
180
+ user-select: none;
181
+ }
182
+
162
183
  .toolbar-zoom-slider {
163
184
  width: 100px;
164
185
  height: 4px;
@@ -230,10 +251,11 @@ export class CalendarViewElement extends LitElement {
230
251
  position: absolute;
231
252
  inset: 0;
232
253
  overflow-y: overlay;
233
- overflow-x: hidden;
254
+ overflow-x: auto;
234
255
  z-index: 1;
235
256
  cursor: default;
236
257
  overflow-anchor: none;
258
+ touch-action: pan-x pan-y;
237
259
  }
238
260
 
239
261
  :host([scroll-lock]) .scroll-container {
@@ -259,7 +281,7 @@ export class CalendarViewElement extends LitElement {
259
281
  left: 60px;
260
282
  right: 12px;
261
283
  pointer-events: none;
262
- z-index: 2;
284
+ z-index: 101;
263
285
  overflow: hidden;
264
286
  }
265
287
 
@@ -300,7 +322,7 @@ export class CalendarViewElement extends LitElement {
300
322
  flex-direction: row;
301
323
  backdrop-filter: blur(10px);
302
324
  box-shadow: var(--shadow-overlay, 0 4px 12px rgba(0, 0, 0, 0.9));
303
- z-index: 100;
325
+ z-index: 102;
304
326
  }
305
327
 
306
328
  .event-detail-header {
@@ -679,6 +701,19 @@ export class CalendarViewElement extends LitElement {
679
701
  text-align: left;
680
702
  }
681
703
 
704
+ .description-actions {
705
+ display: flex;
706
+ gap: 12px;
707
+ align-items: center;
708
+ flex-wrap: wrap;
709
+ }
710
+
711
+ .description-actions .description-see-more {
712
+ display: inline-block;
713
+ margin-top: 8px;
714
+ padding: 0;
715
+ }
716
+
682
717
  .description-see-more:hover {
683
718
  color: var(--accent-hover, rgb(120, 170, 255));
684
719
  }
@@ -728,6 +763,17 @@ export class CalendarViewElement extends LitElement {
728
763
  return this._dayHeight;
729
764
  }
730
765
 
766
+ _minDayColumnWidth = DEFAULT_DAY_COLUMN_WIDTH;
767
+ set minDayColumnWidth(value) {
768
+ this._minDayColumnWidth = value;
769
+ this.saveMinDayColumnWidth();
770
+ this.renderCanvas();
771
+ this.requestUpdate();
772
+ }
773
+ get minDayColumnWidth() {
774
+ return this._minDayColumnWidth;
775
+ }
776
+
731
777
  _scrollTop = 0;
732
778
  set scrollTop(value) {
733
779
  this._scrollTop = value;
@@ -735,7 +781,7 @@ export class CalendarViewElement extends LitElement {
735
781
  if (!this.scrollContainer || !this.scrollContent) return;
736
782
 
737
783
  if (this.scrollContainer.scrollHeight < value) {
738
- this.scrollContent.style.minHeight = value + window.innerHeight + "px";
784
+ this.scrollContent.style.minHeight = `${value + window.innerHeight}px`;
739
785
  }
740
786
 
741
787
  this.scrollContainer.scrollTop = value;
@@ -783,10 +829,13 @@ export class CalendarViewElement extends LitElement {
783
829
 
784
830
  const progress = Math.min((currentTime - startTime) / duration, 1);
785
831
 
786
- this._dayHeight = startDayHeight + (toDayHeight - startDayHeight) * easeOutExpo(progress);
832
+ this._dayHeight =
833
+ startDayHeight +
834
+ (toDayHeight - startDayHeight) * easeOutExpo(progress);
787
835
  this.updateWeekOffsets();
788
836
 
789
- const currentFrac = startFrac + (targetFrac - startFrac) * easeOutCubic(progress);
837
+ const currentFrac =
838
+ startFrac + (targetFrac - startFrac) * easeOutCubic(progress);
790
839
  this._scrollTop = Math.max(0, currentFrac * this.totalHeight);
791
840
 
792
841
  if (this.scrollContainer) {
@@ -794,8 +843,9 @@ export class CalendarViewElement extends LitElement {
794
843
  this.scrollContent &&
795
844
  this.scrollContainer.scrollHeight < this._scrollTop
796
845
  ) {
797
- this.scrollContent.style.minHeight =
798
- this._scrollTop + window.innerHeight + "px";
846
+ this.scrollContent.style.minHeight = `${
847
+ this._scrollTop + window.innerHeight
848
+ }px`;
799
849
  }
800
850
  this.scrollContainer.scrollTop = this._scrollTop;
801
851
  }
@@ -838,6 +888,17 @@ export class CalendarViewElement extends LitElement {
838
888
  this.renderCanvas();
839
889
  }
840
890
 
891
+ syncScrollDomToState(): void {
892
+ if (!this.scrollContainer || !this.scrollContent) return;
893
+
894
+ if (this.scrollContainer.scrollHeight < this._scrollTop) {
895
+ this.scrollContent.style.minHeight = `${
896
+ this._scrollTop + window.innerHeight
897
+ }px`;
898
+ }
899
+ this.scrollContainer.scrollTop = this._scrollTop;
900
+ }
901
+
841
902
  viewportHeight = 0;
842
903
 
843
904
  currentTime = new Date();
@@ -890,8 +951,13 @@ export class CalendarViewElement extends LitElement {
890
951
  timeUpdateInterval: ReturnType<typeof setInterval> | null = null;
891
952
  isExtendingRange = false; // Prevents concurrent range extensions
892
953
  boundaryCheckInterval: ReturnType<typeof setInterval> | null = null;
954
+ wheelZoomSyncTimeout: ReturnType<typeof setTimeout> | null = null;
893
955
  lastWheelTime = 0; // Timestamp of last wheel event, used to distinguish wheel vs scrollbar drag
894
956
  isCreatingEvent = false;
957
+ isPinchZooming = false;
958
+ pinchStartDistance = 0;
959
+ pinchStartHeight = MIN_DAY_HEIGHT;
960
+ pinchStartOriginY = 0;
895
961
  eventCreationStart: { x: number; y: number } | null = null;
896
962
  eventCreationEnd: { x: number; y: number } | null = null;
897
963
  eventCreationShiftPressed = false;
@@ -912,28 +978,49 @@ export class CalendarViewElement extends LitElement {
912
978
  resizingOriginalEnd: Date | null = null;
913
979
  isResizingEvent = false;
914
980
 
915
- _columnsPerRow = 7;
916
- set columnsPerRow(value: number) {
917
- const clamped = Math.max(1, Math.min(7, Math.floor(value)));
918
- if (this._columnsPerRow !== clamped) {
919
- this._columnsPerRow = clamped;
920
- this.updateWeekOffsets();
921
- this.renderCanvas();
922
- this.requestUpdate();
923
- }
924
- }
925
981
  get columnsPerRow(): number {
926
- return this._columnsPerRow;
982
+ return 7;
927
983
  }
928
984
 
929
985
  get rowsPerWeek(): number {
930
- return Math.ceil(7 / this._columnsPerRow);
986
+ return 1;
931
987
  }
932
988
 
933
989
  getDayVisualPosition(dayIndex: number): { row: number; col: number } {
934
- const row = Math.floor(dayIndex / this._columnsPerRow);
935
- const col = dayIndex % this._columnsPerRow;
936
- return { row, col };
990
+ return { row: 0, col: dayIndex };
991
+ }
992
+
993
+ get scrollLeft(): number {
994
+ return this.scrollContainer?.scrollLeft ?? 0;
995
+ }
996
+
997
+ getViewportGridWidth(): number {
998
+ if (!this.scrollContainer) return this.columnsPerRow * this.minDayColumnWidth;
999
+ return Math.max(
1000
+ 0,
1001
+ this.scrollContainer.clientWidth - LEFT_GUTTER_WIDTH - MINIMAP_WIDTH,
1002
+ );
1003
+ }
1004
+
1005
+ getGridWidth(): number {
1006
+ return Math.max(
1007
+ this.getViewportGridWidth(),
1008
+ this.columnsPerRow * this.minDayColumnWidth,
1009
+ );
1010
+ }
1011
+
1012
+ getDayWidth(): number {
1013
+ return this.getGridWidth() / this.columnsPerRow;
1014
+ }
1015
+
1016
+ getContentWidth(): number {
1017
+ return LEFT_GUTTER_WIDTH + this.getGridWidth();
1018
+ }
1019
+
1020
+ getContentXFromClientX(clientX: number): number {
1021
+ if (!this.scrollContainer) return clientX;
1022
+ const rect = this.scrollContainer.getBoundingClientRect();
1023
+ return clientX - rect.left + this.scrollLeft;
937
1024
  }
938
1025
 
939
1026
  getVisualPositionFromCoords(
@@ -943,9 +1030,9 @@ export class CalendarViewElement extends LitElement {
943
1030
  ): { dayIndex: number; timeFraction: number; weekYOffset: number } | null {
944
1031
  if (!this.scrollContainer) return null;
945
1032
 
946
- const dayWidth = gridWidth / this._columnsPerRow;
1033
+ const dayWidth = gridWidth / this.columnsPerRow;
947
1034
  const col = Math.floor((x - LEFT_GUTTER_WIDTH) / dayWidth);
948
- if (col < 0 || col >= this._columnsPerRow) return null;
1035
+ if (col < 0 || col >= this.columnsPerRow) return null;
949
1036
 
950
1037
  const week = this.weeks.find(
951
1038
  (w) => w.height > 0 && y >= w.yOffset && y < w.yOffset + w.height,
@@ -954,7 +1041,7 @@ export class CalendarViewElement extends LitElement {
954
1041
 
955
1042
  const rowHeight = this.dayHeight;
956
1043
  const row = Math.floor((y - week.yOffset) / rowHeight);
957
- const dayIndex = row * this._columnsPerRow + col;
1044
+ const dayIndex = row * this.columnsPerRow + col;
958
1045
  if (dayIndex < 0 || dayIndex > 6) return null;
959
1046
 
960
1047
  const timeFraction = ((y - week.yOffset) % rowHeight) / rowHeight;
@@ -992,6 +1079,11 @@ export class CalendarViewElement extends LitElement {
992
1079
  height: number;
993
1080
  } | null = null;
994
1081
  isDescriptionExpanded = false;
1082
+ descriptionSummaryTargetKey: string | null = null;
1083
+ descriptionSummaryText = "";
1084
+ descriptionSummaryLoading = false;
1085
+ descriptionSummaryError: string | null = null;
1086
+ private requestedDescriptionSummaryKey: string | null = null;
995
1087
 
996
1088
  notificationPopoverOpen = false;
997
1089
  scheduledNotifications: any[] = [];
@@ -1037,7 +1129,7 @@ export class CalendarViewElement extends LitElement {
1037
1129
  if (saved) {
1038
1130
  return Math.max(
1039
1131
  MIN_DAY_HEIGHT,
1040
- Math.min(MAX_DAY_HEIGHT, parseFloat(saved)),
1132
+ Math.min(MAX_DAY_HEIGHT, Number.parseFloat(saved)),
1041
1133
  );
1042
1134
  }
1043
1135
 
@@ -1048,6 +1140,25 @@ export class CalendarViewElement extends LitElement {
1048
1140
  localStorage.setItem("calendar-dayHeight", this.dayHeight.toString());
1049
1141
  }
1050
1142
 
1143
+ loadMinDayColumnWidth(): number {
1144
+ const saved = localStorage.getItem("calendar-minDayColumnWidth");
1145
+ if (saved) {
1146
+ return Math.max(
1147
+ MIN_DAY_COLUMN_WIDTH,
1148
+ Math.min(MAX_DAY_COLUMN_WIDTH, Number.parseFloat(saved)),
1149
+ );
1150
+ }
1151
+
1152
+ return DEFAULT_DAY_COLUMN_WIDTH;
1153
+ }
1154
+
1155
+ saveMinDayColumnWidth(): void {
1156
+ localStorage.setItem(
1157
+ "calendar-minDayColumnWidth",
1158
+ this.minDayColumnWidth.toString(),
1159
+ );
1160
+ }
1161
+
1051
1162
  saveScrollPosition(): void {
1052
1163
  // Save the date at the center of the viewport instead of pixel offset
1053
1164
  const centerY = this.scrollTop + this.viewportHeight / 2;
@@ -1066,6 +1177,90 @@ export class CalendarViewElement extends LitElement {
1066
1177
  }
1067
1178
  }
1068
1179
 
1180
+ renderMonthLabels(
1181
+ ctx: CanvasRenderingContext2D,
1182
+ width: number,
1183
+ height: number,
1184
+ fontFamily: string,
1185
+ ): void {
1186
+ const monthNames = [
1187
+ "January",
1188
+ "February",
1189
+ "March",
1190
+ "April",
1191
+ "May",
1192
+ "June",
1193
+ "July",
1194
+ "August",
1195
+ "September",
1196
+ "October",
1197
+ "November",
1198
+ "December",
1199
+ ];
1200
+ const monthBoundaries: Array<{
1201
+ monthName: string;
1202
+ year: number;
1203
+ yOffset: number;
1204
+ }> = [];
1205
+ let previousMonthKey: string | null = null;
1206
+
1207
+ for (const week of this.weeks) {
1208
+ if (week.height === 0) continue;
1209
+ if (week.yOffset + week.height < this.scrollTop) continue;
1210
+ if (week.yOffset > this.scrollTop + this.viewportHeight) break;
1211
+
1212
+ for (const day of week.days) {
1213
+ if (!day) continue;
1214
+ const monthIndex = day.getMonth();
1215
+ const year = day.getFullYear();
1216
+ const monthKey = `${monthIndex}-${year}`;
1217
+ if (monthKey === previousMonthKey) continue;
1218
+
1219
+ previousMonthKey = monthKey;
1220
+ const monthName = monthNames[monthIndex];
1221
+ if (!monthName) continue;
1222
+ monthBoundaries.push({ monthName, year, yOffset: week.yOffset });
1223
+ }
1224
+ }
1225
+
1226
+ const bgPrimary =
1227
+ getComputedStyle(this).getPropertyValue("--bg-primary").trim() ||
1228
+ "rgba(30, 30, 30, 1)";
1229
+ const monthTextPrimary =
1230
+ getComputedStyle(this).getPropertyValue("--text-primary").trim() ||
1231
+ "rgba(255, 255, 255, 0.95)";
1232
+
1233
+ for (let i = 0; i < monthBoundaries.length; i++) {
1234
+ const month = monthBoundaries[i];
1235
+ if (!month) continue;
1236
+ const nextMonth = monthBoundaries[i + 1];
1237
+ const nextMonthY = nextMonth
1238
+ ? nextMonth.yOffset
1239
+ : Number.POSITIVE_INFINITY;
1240
+
1241
+ if (nextMonthY < this.scrollTop) continue;
1242
+ if (month.yOffset > this.scrollTop + this.viewportHeight) break;
1243
+
1244
+ const stickyTop = Math.max(0, this.scrollTop - month.yOffset);
1245
+ const maxStickyTop = nextMonthY - month.yOffset - 24;
1246
+ const clampedStickyTop = Math.min(stickyTop, maxStickyTop);
1247
+ const finalTop = month.yOffset + clampedStickyTop - this.scrollTop;
1248
+ const isSticky = clampedStickyTop > 0;
1249
+
1250
+ ctx.save();
1251
+ ctx.globalAlpha = isSticky ? 1 : 0.5;
1252
+ ctx.fillStyle = bgPrimary;
1253
+ ctx.fillRect(0, finalTop, width, 40);
1254
+ ctx.globalAlpha = 1;
1255
+ ctx.font = `bold 18px ${fontFamily}`;
1256
+ ctx.textAlign = "left";
1257
+ ctx.textBaseline = "top";
1258
+ ctx.fillStyle = monthTextPrimary;
1259
+ ctx.fillText(`${month.monthName} ${month.year}`, 20, finalTop + 10);
1260
+ ctx.restore();
1261
+ }
1262
+ }
1263
+
1069
1264
  loadScrollPosition(): void {
1070
1265
  const saved = localStorage.getItem("calendar-scrollDate");
1071
1266
  if (saved) {
@@ -1099,9 +1294,8 @@ export class CalendarViewElement extends LitElement {
1099
1294
  CalendarInternal.isSameDay(d, now),
1100
1295
  );
1101
1296
  if (todayIndex >= 0) {
1102
- const row = Math.floor(todayIndex / this._columnsPerRow);
1103
1297
  const timeFraction = (now.getHours() + now.getMinutes() / 60) / 24;
1104
- offsetInWeek = (row + timeFraction) / this.rowsPerWeek;
1298
+ offsetInWeek = timeFraction;
1105
1299
  }
1106
1300
  }
1107
1301
 
@@ -1328,8 +1522,17 @@ export class CalendarViewElement extends LitElement {
1328
1522
  window.removeEventListener("paste", this.onPaste);
1329
1523
  window.removeEventListener("keydown", this.onKeyDown);
1330
1524
  window.removeEventListener("keyup", this.onKeyUp);
1525
+ if (this.wheelZoomSyncTimeout) {
1526
+ clearTimeout(this.wheelZoomSyncTimeout);
1527
+ this.wheelZoomSyncTimeout = null;
1528
+ }
1331
1529
 
1332
1530
  if (this.scrollContainer) {
1531
+ this.scrollContainer.removeEventListener("scroll", this.onScroll);
1532
+ this.scrollContainer.removeEventListener("touchstart", this.onTouchStart);
1533
+ this.scrollContainer.removeEventListener("touchmove", this.onTouchMove);
1534
+ this.scrollContainer.removeEventListener("touchend", this.onTouchEnd);
1535
+ this.scrollContainer.removeEventListener("touchcancel", this.onTouchEnd);
1333
1536
  this.scrollContainer.removeEventListener(
1334
1537
  "mouseleave",
1335
1538
  this.onScrollContainerMouseLeave,
@@ -1359,21 +1562,141 @@ export class CalendarViewElement extends LitElement {
1359
1562
  this.repositionEventDetailOverlay();
1360
1563
  }
1361
1564
 
1565
+ clearDescriptionSummary(): void {
1566
+ if (this.requestedDescriptionSummaryKey) {
1567
+ this.dispatchEvent(
1568
+ new CustomEvent("cancel-description-summary", {
1569
+ detail: { key: this.requestedDescriptionSummaryKey },
1570
+ bubbles: true,
1571
+ composed: true,
1572
+ }),
1573
+ );
1574
+ }
1575
+ this.requestedDescriptionSummaryKey = null;
1576
+ this.descriptionSummaryTargetKey = null;
1577
+ this.descriptionSummaryText = "";
1578
+ this.descriptionSummaryLoading = false;
1579
+ this.descriptionSummaryError = null;
1580
+ this.requestUpdate();
1581
+ }
1582
+
1583
+ private getDescriptionSummaryTargetKey(event: CalendarEvent): string {
1584
+ return `${event.id}:${sanitizeEventDescription(event.description ?? "")}`;
1585
+ }
1586
+
1587
+ private requestDescriptionSummary(event: CalendarEvent): void {
1588
+ const description = sanitizeEventDescription(
1589
+ event.description ?? "",
1590
+ ).trim();
1591
+ if (description.length <= 200) return;
1592
+
1593
+ const targetKey = this.getDescriptionSummaryTargetKey(event);
1594
+ this.clearDescriptionSummary();
1595
+ this.requestedDescriptionSummaryKey = targetKey;
1596
+ this.descriptionSummaryTargetKey = targetKey;
1597
+ this.descriptionSummaryText = "";
1598
+ this.descriptionSummaryLoading = true;
1599
+ this.descriptionSummaryError = null;
1600
+ this.dispatchEvent(
1601
+ new CustomEvent("request-description-summary", {
1602
+ detail: {
1603
+ event,
1604
+ key: targetKey,
1605
+ description,
1606
+ },
1607
+ bubbles: true,
1608
+ composed: true,
1609
+ }),
1610
+ );
1611
+ this.requestUpdate();
1612
+ }
1613
+
1614
+ startDescriptionSummary(key: string): void {
1615
+ if (key !== this.descriptionSummaryTargetKey) return;
1616
+ this.descriptionSummaryText = "";
1617
+ this.descriptionSummaryLoading = true;
1618
+ this.descriptionSummaryError = null;
1619
+ this.requestUpdate();
1620
+ }
1621
+
1622
+ appendDescriptionSummaryChunk(key: string, chunk: string): void {
1623
+ if (key !== this.descriptionSummaryTargetKey) return;
1624
+ if (!chunk) return;
1625
+ this.descriptionSummaryText += chunk;
1626
+ this.descriptionSummaryLoading = true;
1627
+ this.requestUpdate();
1628
+ }
1629
+
1630
+ finishDescriptionSummary(key: string, error: string | null = null): void {
1631
+ if (key !== this.descriptionSummaryTargetKey) return;
1632
+ this.descriptionSummaryLoading = false;
1633
+ this.descriptionSummaryError = error;
1634
+ this.requestUpdate();
1635
+ }
1636
+
1637
+ failDescriptionSummary(key: string, message: string): void {
1638
+ if (key !== this.descriptionSummaryTargetKey) return;
1639
+ this.descriptionSummaryLoading = false;
1640
+ this.descriptionSummaryError = message;
1641
+ if (
1642
+ !this.descriptionSummaryText &&
1643
+ this.selectedEventForDetail?.description
1644
+ ) {
1645
+ this.descriptionSummaryText =
1646
+ this.selectedEventForDetail.description.replaceAll("\\n", "\n");
1647
+ }
1648
+ this.requestUpdate();
1649
+ }
1650
+
1651
+ cancelDescriptionSummary(key: string): void {
1652
+ if (key !== this.descriptionSummaryTargetKey) {
1653
+ return;
1654
+ }
1655
+ this.descriptionSummaryLoading = false;
1656
+ this.requestUpdate();
1657
+ }
1658
+
1362
1659
  repositionEventDetailOverlay(): void {
1363
1660
  if (!this.selectedEventForDetail || !this.selectedEventRect) return;
1364
1661
 
1365
- const overlay = this.renderRoot.querySelector<HTMLElement>(".event-detail-overlay");
1662
+ const overlay = this.renderRoot.querySelector<HTMLElement>(
1663
+ ".event-detail-overlay",
1664
+ );
1366
1665
  if (!overlay) return;
1666
+ const currentRect = this.eventRects.find(
1667
+ (rect) => rect.event === this.selectedEventForDetail,
1668
+ );
1669
+ if (currentRect) {
1670
+ this.selectedEventRect = {
1671
+ x: currentRect.x,
1672
+ y: currentRect.y,
1673
+ width: currentRect.width,
1674
+ height: currentRect.height,
1675
+ };
1676
+ }
1367
1677
 
1678
+ const actualWidth = overlay.offsetWidth;
1368
1679
  const actualHeight = overlay.offsetHeight;
1369
1680
  const GAP = 8;
1681
+ const containerWidth = this.scrollContainer?.clientWidth || 800;
1370
1682
  const containerHeight = this.scrollContainer?.clientHeight || 600;
1371
1683
 
1684
+ const rightPosition =
1685
+ this.selectedEventRect.x + this.selectedEventRect.width + GAP;
1686
+ const leftPosition = this.selectedEventRect.x - actualWidth - GAP;
1687
+ const preferredLeft =
1688
+ rightPosition + actualWidth <= containerWidth
1689
+ ? rightPosition
1690
+ : leftPosition;
1691
+ const minLeft = LEFT_GUTTER_WIDTH + GAP;
1692
+ const maxLeft = containerWidth - actualWidth - GAP;
1693
+ const left = Math.max(minLeft, Math.min(maxLeft, preferredLeft));
1372
1694
  const rawTop = this.selectedEventRect.y - this.scrollTop;
1373
1695
  const minTop = GAP;
1374
1696
  const maxTop = containerHeight - actualHeight - GAP;
1375
1697
  const top = Math.max(minTop, Math.min(maxTop, rawTop));
1376
1698
 
1699
+ overlay.style.left = `${left}px`;
1377
1700
  overlay.style.top = `${top}px`;
1378
1701
  }
1379
1702
 
@@ -1390,14 +1713,24 @@ export class CalendarViewElement extends LitElement {
1390
1713
 
1391
1714
  const self = this;
1392
1715
  const eventsState: EventsState = {
1393
- get events() { return self.events; },
1394
- get hoveredEventId() { return self.hoveredEventId; },
1716
+ get events() {
1717
+ return self.events;
1718
+ },
1719
+ get previewEvents() {
1720
+ return self.getPreviewEvents();
1721
+ },
1722
+ get hoveredEventId() {
1723
+ return self.hoveredEventId;
1724
+ },
1395
1725
  isEventSelected: (event) => self.internal.isEventSelected(event),
1396
- shouldRenderEventWithStripes: (event) => self.shouldRenderEventWithStripes(event),
1726
+ shouldRenderEventWithStripes: (event) =>
1727
+ self.shouldRenderEventWithStripes(event),
1397
1728
  getStripePatternCanvas: () => self.getStripePatternCanvas(),
1398
1729
  };
1399
1730
  const heatmapState = {
1400
- get events() { return self.heatmapEvents; },
1731
+ get events() {
1732
+ return self.heatmapEvents;
1733
+ },
1401
1734
  };
1402
1735
  this.eventsLayer = createEventsLayer(eventsState);
1403
1736
  this.layers = [
@@ -1410,14 +1743,30 @@ export class CalendarViewElement extends LitElement {
1410
1743
 
1411
1744
  // Restore zoom level from localStorage
1412
1745
  const savedDayHeight = this.loadDayHeight();
1413
- if (savedDayHeight !== 80) {
1746
+ if (savedDayHeight !== MIN_DAY_HEIGHT) {
1414
1747
  this.dayHeight = savedDayHeight;
1415
1748
  }
1749
+ const savedMinDayColumnWidth = this.loadMinDayColumnWidth();
1750
+ if (savedMinDayColumnWidth !== DEFAULT_DAY_COLUMN_WIDTH) {
1751
+ this.minDayColumnWidth = savedMinDayColumnWidth;
1752
+ }
1416
1753
 
1417
1754
  if (this.scrollContainer) {
1418
1755
  this.scrollContainer.addEventListener("scroll", this.onScroll, {
1419
1756
  passive: false,
1420
1757
  });
1758
+ this.scrollContainer.addEventListener("touchstart", this.onTouchStart, {
1759
+ passive: false,
1760
+ });
1761
+ this.scrollContainer.addEventListener("touchmove", this.onTouchMove, {
1762
+ passive: false,
1763
+ });
1764
+ this.scrollContainer.addEventListener("touchend", this.onTouchEnd, {
1765
+ passive: false,
1766
+ });
1767
+ this.scrollContainer.addEventListener("touchcancel", this.onTouchEnd, {
1768
+ passive: false,
1769
+ });
1421
1770
  this.scrollContainer.addEventListener(
1422
1771
  "mouseleave",
1423
1772
  this.onScrollContainerMouseLeave,
@@ -1529,30 +1878,27 @@ export class CalendarViewElement extends LitElement {
1529
1878
 
1530
1879
  updateWeekOffsets(): void {
1531
1880
  let y = 0;
1532
- const weekHeight = this.dayHeight * this.rowsPerWeek;
1881
+ const weekHeight = this.dayHeight;
1533
1882
 
1534
1883
  if (this.filter) {
1535
- const filteredEvents = this.events;
1536
-
1537
- // Pre-compute event date ranges once (avoiding repeated startOfDayTime/endOfDayTime calls)
1538
- const eventRanges = filteredEvents.map((e) => ({
1539
- start: CalendarInternal.startOfDayTime(e.start),
1540
- end: CalendarInternal.endOfDayTime(e.end),
1541
- }));
1884
+ const eventRanges = this.getFilteredEventRanges(this.events);
1885
+ this.ensureFilteredContextWeeks(eventRanges);
1886
+
1887
+ const visibleWeekIndexes = new Set<number>();
1888
+ for (const index of this.getMatchingWeekIndexes(eventRanges)) {
1889
+ for (
1890
+ let visibleIndex = Math.max(0, index - FILTER_CONTEXT_WEEKS);
1891
+ visibleIndex <=
1892
+ Math.min(this.weeks.length - 1, index + FILTER_CONTEXT_WEEKS);
1893
+ visibleIndex++
1894
+ ) {
1895
+ visibleWeekIndexes.add(visibleIndex);
1896
+ }
1897
+ }
1542
1898
 
1543
- for (const week of this.weeks) {
1899
+ for (const [index, week] of this.weeks.entries()) {
1544
1900
  week.yOffset = y;
1545
-
1546
- // Check if any day in this week overlaps any event range
1547
- const weekStartTime = week.days[0]?.getTime() ?? 0;
1548
- const weekEndTime = week.days[6]?.getTime() ?? 0;
1549
-
1550
- // Quick check: skip if week is entirely outside all event ranges
1551
- const hasEvents = eventRanges.some(
1552
- (range) => range.end >= weekStartTime && range.start <= weekEndTime,
1553
- );
1554
-
1555
- week.height = hasEvents ? weekHeight : 0;
1901
+ week.height = visibleWeekIndexes.has(index) ? weekHeight : 0;
1556
1902
  y += week.height;
1557
1903
  }
1558
1904
  } else {
@@ -1566,6 +1912,77 @@ export class CalendarViewElement extends LitElement {
1566
1912
  this.totalHeight = y;
1567
1913
  }
1568
1914
 
1915
+ private getFilteredEventRanges(
1916
+ events: CalendarEvent[],
1917
+ ): Array<{ start: number; end: number }> {
1918
+ return events
1919
+ .map((event) => ({
1920
+ start: CalendarInternal.startOfDayTime(event.start),
1921
+ end: CalendarInternal.endOfDayTime(event.end),
1922
+ }))
1923
+ .sort((a, b) => a.start - b.start);
1924
+ }
1925
+
1926
+ private getMatchingWeekIndexes(
1927
+ eventRanges: Array<{ start: number; end: number }>,
1928
+ ): number[] {
1929
+ const matchingIndexes: number[] = [];
1930
+
1931
+ for (const [index, week] of this.weeks.entries()) {
1932
+ const weekStartTime = CalendarInternal.startOfDayTime(
1933
+ week.days[0] ?? new Date(0),
1934
+ );
1935
+ const weekEndTime = CalendarInternal.endOfDayTime(
1936
+ week.days[6] ?? new Date(0),
1937
+ );
1938
+ const hasEvents = eventRanges.some(
1939
+ (range) => range.end >= weekStartTime && range.start <= weekEndTime,
1940
+ );
1941
+ if (hasEvents) {
1942
+ matchingIndexes.push(index);
1943
+ }
1944
+ }
1945
+
1946
+ return matchingIndexes;
1947
+ }
1948
+
1949
+ private ensureFilteredContextWeeks(
1950
+ eventRanges: Array<{ start: number; end: number }>,
1951
+ ): void {
1952
+ if (eventRanges.length === 0) return;
1953
+
1954
+ let matchingWeekIndexes = this.getMatchingWeekIndexes(eventRanges);
1955
+ if (matchingWeekIndexes.length === 0) {
1956
+ this.weeks = this.internal.resetRangeAroundDate(
1957
+ new Date(eventRanges[0]?.start),
1958
+ );
1959
+ matchingWeekIndexes = this.getMatchingWeekIndexes(eventRanges);
1960
+ }
1961
+
1962
+ while (
1963
+ matchingWeekIndexes.length > 0 &&
1964
+ matchingWeekIndexes[0]! < FILTER_CONTEXT_WEEKS
1965
+ ) {
1966
+ const newWeeks = this.internal.extendRange("past");
1967
+ if (newWeeks.length === 0) break;
1968
+ this.weeks = [...newWeeks, ...this.weeks];
1969
+ matchingWeekIndexes = this.getMatchingWeekIndexes(eventRanges);
1970
+ }
1971
+
1972
+ while (
1973
+ matchingWeekIndexes.length > 0 &&
1974
+ this.weeks.length -
1975
+ 1 -
1976
+ matchingWeekIndexes[matchingWeekIndexes.length - 1]! <
1977
+ FILTER_CONTEXT_WEEKS
1978
+ ) {
1979
+ const newWeeks = this.internal.extendRange("future");
1980
+ if (newWeeks.length === 0) break;
1981
+ this.weeks = [...this.weeks, ...newWeeks];
1982
+ matchingWeekIndexes = this.getMatchingWeekIndexes(eventRanges);
1983
+ }
1984
+ }
1985
+
1569
1986
  handleResize(): void {
1570
1987
  if (!this.canvas || !this.scrollContainer) return;
1571
1988
 
@@ -1574,8 +1991,11 @@ export class CalendarViewElement extends LitElement {
1574
1991
 
1575
1992
  this.rect = rect;
1576
1993
 
1577
- this.canvas.width = rect.width * dpr;
1994
+ const canvasWidth = LEFT_GUTTER_WIDTH + this.getViewportGridWidth();
1995
+ this.canvas.width = canvasWidth * dpr;
1578
1996
  this.canvas.height = rect.height * dpr;
1997
+ this.canvas.style.width = `${canvasWidth}px`;
1998
+ this.canvas.style.height = `${rect.height}px`;
1579
1999
  this.viewportHeight = rect.height;
1580
2000
 
1581
2001
  // Reset and rescale context (scale is reset when canvas dimensions change)
@@ -1586,7 +2006,7 @@ export class CalendarViewElement extends LitElement {
1586
2006
 
1587
2007
  // Resize and configure overlay canvas
1588
2008
  if (this.overlayCanvas) {
1589
- const overlayWidth = rect.width - LEFT_GUTTER_WIDTH - MINIMAP_WIDTH;
2009
+ const overlayWidth = this.getViewportGridWidth();
1590
2010
  this.overlayCanvas.width = overlayWidth * dpr;
1591
2011
  this.overlayCanvas.height = rect.height * dpr;
1592
2012
  this.overlayCanvas.style.width = `${overlayWidth}px`;
@@ -1598,30 +2018,9 @@ export class CalendarViewElement extends LitElement {
1598
2018
  }
1599
2019
  }
1600
2020
 
1601
- this.updateColumnsForViewport();
1602
2021
  this.renderCanvas();
1603
2022
  }
1604
2023
 
1605
- updateColumnsForViewport(): void {
1606
- if (!this.scrollContainer) return;
1607
-
1608
- const rect = this.scrollContainer.getBoundingClientRect();
1609
- const gridWidth = rect.width - LEFT_GUTTER_WIDTH;
1610
-
1611
- // Determine optimal columns based on available width
1612
- // Minimum 60px per day column for usability
1613
- const minDayWidth = 120;
1614
- const optimalColumns = Math.max(
1615
- 1,
1616
- Math.min(7, Math.floor(gridWidth / minDayWidth)),
1617
- );
1618
-
1619
- if (this._columnsPerRow !== optimalColumns) {
1620
- this._columnsPerRow = optimalColumns;
1621
- this.updateWeekOffsets();
1622
- }
1623
- }
1624
-
1625
2024
  resolveStyles(): Record<string, string> {
1626
2025
  const cs = getComputedStyle(this);
1627
2026
  const props = [
@@ -1651,7 +2050,10 @@ export class CalendarViewElement extends LitElement {
1651
2050
  const ids = new Set<string>();
1652
2051
  if (saved) {
1653
2052
  try {
1654
- const sources = JSON.parse(saved) as Array<{ id?: string; type?: string }>;
2053
+ const sources = JSON.parse(saved) as Array<{
2054
+ id?: string;
2055
+ type?: string;
2056
+ }>;
1655
2057
  for (const source of sources) {
1656
2058
  if (source?.type === "timeseries-json" && source.id) {
1657
2059
  ids.add(source.id);
@@ -1692,7 +2094,9 @@ export class CalendarViewElement extends LitElement {
1692
2094
  const timeseriesIds = this.loadTimeseriesSourceIds();
1693
2095
  const enabledKey = [...this.internal.enabledCalendars].join(",");
1694
2096
  const lockedKey = [...this.internal.lockedCalendars].join(",");
1695
- const key = `${start.toISOString()}::${end.toISOString()}::${this.filter || ""}::${enabledKey}::${lockedKey}`;
2097
+ const key = `${start.toISOString()}::${end.toISOString()}::${
2098
+ this.filter || ""
2099
+ }::${enabledKey}::${lockedKey}`;
1696
2100
  if (!force && key === this.heatmapQueryKey) return;
1697
2101
  this.heatmapQueryKey = key;
1698
2102
 
@@ -1728,8 +2132,7 @@ export class CalendarViewElement extends LitElement {
1728
2132
  ctx.clearRect(0, 0, width, height);
1729
2133
 
1730
2134
  const scrollTop = this.scrollTop;
1731
- const gridWidth = width - LEFT_GUTTER_WIDTH;
1732
- const dayWidth = gridWidth / this._columnsPerRow;
2135
+ const dayWidth = this.getDayWidth();
1733
2136
 
1734
2137
  const visibleWeeks = this.weeks.filter(
1735
2138
  (w) =>
@@ -1745,10 +2148,11 @@ export class CalendarViewElement extends LitElement {
1745
2148
  width,
1746
2149
  height,
1747
2150
  scrollTop,
2151
+ scrollLeft: this.scrollLeft,
1748
2152
  dayWidth,
1749
2153
  dayHeight: this.dayHeight,
1750
2154
  leftGutterWidth: LEFT_GUTTER_WIDTH,
1751
- columnsPerRow: this._columnsPerRow,
2155
+ columnsPerRow: this.columnsPerRow,
1752
2156
  rowsPerWeek: this.rowsPerWeek,
1753
2157
  visibleWeeks,
1754
2158
  allWeeks: this.weeks,
@@ -1765,6 +2169,14 @@ export class CalendarViewElement extends LitElement {
1765
2169
  ctx.restore();
1766
2170
  }
1767
2171
 
2172
+ this.renderTimeScaleGutter(
2173
+ ctx,
2174
+ visibleWeeks,
2175
+ height,
2176
+ fontFamily,
2177
+ lc.styles,
2178
+ );
2179
+
1768
2180
  // Copy event rects from events layer for hit-testing
1769
2181
  if (this.eventsLayer) {
1770
2182
  this.eventRects = this.eventsLayer.eventRects;
@@ -1777,14 +2189,102 @@ export class CalendarViewElement extends LitElement {
1777
2189
  // Draw sticky weekday labels at top of viewport
1778
2190
  this.renderWeekdayLabels(ctx, dayWidth, visibleWeeks, scrollTop, height);
1779
2191
 
1780
- if (this.isCreatingEvent) {
1781
- this.renderEventCreationPreview();
1782
- }
1783
- if (this.isDraggingEvent) {
1784
- this.renderEventMovePreview();
2192
+ this.renderMinimap();
2193
+ }
2194
+
2195
+ renderTimeScaleGutter(
2196
+ ctx: CanvasRenderingContext2D,
2197
+ visibleWeeks: WeekInfo[],
2198
+ height: number,
2199
+ fontFamily: string,
2200
+ styles: Record<string, string>,
2201
+ ): void {
2202
+ if (visibleWeeks.length === 0) return;
2203
+
2204
+ const hourLabelOpacity = Math.max(
2205
+ 0,
2206
+ Math.min(1, (this.dayHeight - TIME_SCALE_DAY_HEIGHT) / 300),
2207
+ );
2208
+ if (hourLabelOpacity <= 0.1) return;
2209
+
2210
+ const textMuted = styles["--text-muted"] || "rgba(255, 255, 255, 0.4)";
2211
+ const textPrimary = styles["--text-primary"] || "rgba(255, 255, 255, 1)";
2212
+ const bgPrimary = styles["--bg-primary"] || "rgba(30, 30, 30, 0.9)";
2213
+ const bgElevated = styles["--bg-elevated"] || "rgba(0, 0, 0, 0.7)";
2214
+ const today = new Date();
2215
+
2216
+ ctx.save();
2217
+ ctx.font = `500 11px ${fontFamily}`;
2218
+ ctx.textBaseline = "bottom";
2219
+ ctx.textAlign = "right";
2220
+ ctx.fillStyle = textMuted.replace(
2221
+ /[\d.]+\)$/,
2222
+ `${0.4 * hourLabelOpacity})`,
2223
+ );
2224
+
2225
+ for (const week of visibleWeeks) {
2226
+ const y = week.yOffset - this.scrollTop;
2227
+ const visibleWeekTop = Math.max(0, y);
2228
+ const visibleWeekBottom = Math.min(height, y + week.height);
2229
+
2230
+ if (visibleWeekBottom > visibleWeekTop) {
2231
+ ctx.save();
2232
+ ctx.globalAlpha = 0.7 * hourLabelOpacity;
2233
+ ctx.fillStyle = bgPrimary;
2234
+ ctx.fillRect(
2235
+ 0,
2236
+ visibleWeekTop,
2237
+ LEFT_GUTTER_WIDTH - 1,
2238
+ visibleWeekBottom - visibleWeekTop,
2239
+ );
2240
+ ctx.restore();
2241
+ }
2242
+
2243
+ for (let hour = 0; hour < 24; hour++) {
2244
+ const hourY = y + (hour / 24) * this.dayHeight;
2245
+ if (hourY < 0 || hourY > height) continue;
2246
+ const label = `${hour.toString().padStart(2, "0")}:00`;
2247
+ ctx.fillText(label, 48, hourY + 4);
2248
+ }
2249
+
2250
+ const todayIndex = week.days.findIndex((d) =>
2251
+ CalendarInternal.isSameDay(d, today),
2252
+ );
2253
+ if (todayIndex < 0) continue;
2254
+
2255
+ const currentMinutes = today.getHours() * 60 + today.getMinutes();
2256
+ const timeY = y + (currentMinutes / 1440) * this.dayHeight;
2257
+ if (timeY < 0 || timeY > height) continue;
2258
+
2259
+ const hours = today.getHours().toString().padStart(2, "0");
2260
+ const minutes = today.getMinutes().toString().padStart(2, "0");
2261
+ const timeText = `${hours}:${minutes}`;
2262
+ const textWidth = ctx.measureText(timeText).width;
2263
+ const bgPaddingX = 6;
2264
+ const textX = 48;
2265
+
2266
+ ctx.fillStyle = bgElevated;
2267
+ ctx.beginPath();
2268
+ ctx.roundRect(
2269
+ textX - textWidth - bgPaddingX,
2270
+ timeY - 8,
2271
+ textWidth + bgPaddingX * 2,
2272
+ 16,
2273
+ 4,
2274
+ );
2275
+ ctx.fill();
2276
+
2277
+ ctx.fillStyle = textPrimary;
2278
+ ctx.textBaseline = "middle";
2279
+ ctx.fillText(timeText, textX, timeY);
2280
+ ctx.textBaseline = "bottom";
2281
+ ctx.fillStyle = textMuted.replace(
2282
+ /[\d.]+\)$/,
2283
+ `${0.4 * hourLabelOpacity})`,
2284
+ );
1785
2285
  }
1786
2286
 
1787
- this.renderMinimap();
2287
+ ctx.restore();
1788
2288
  }
1789
2289
 
1790
2290
  toggleLayer(name: string): void {
@@ -1794,7 +2294,6 @@ export class CalendarViewElement extends LitElement {
1794
2294
  this.renderCanvas();
1795
2295
  }
1796
2296
 
1797
-
1798
2297
  onWheel = (e: WheelEvent): void => {
1799
2298
  if (this.hasAttribute("scroll-lock")) return;
1800
2299
  this.lastWheelTime = Date.now();
@@ -1804,7 +2303,9 @@ export class CalendarViewElement extends LitElement {
1804
2303
  }
1805
2304
 
1806
2305
  const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
1807
- const isZoomKey = isMac ? e.metaKey : e.ctrlKey;
2306
+ const isPinchZoomGesture = e.ctrlKey;
2307
+ const isShortcutZoom = isMac ? e.metaKey : e.ctrlKey;
2308
+ const isZoomGesture = isPinchZoomGesture || isShortcutZoom;
1808
2309
 
1809
2310
  if (this.isFiltered && this.historyIndex === 0) {
1810
2311
  this.replaceFilterInHistory();
@@ -1812,7 +2313,7 @@ export class CalendarViewElement extends LitElement {
1812
2313
  this.debouncedSaveToHistory();
1813
2314
  }
1814
2315
 
1815
- if (!isZoomKey || !this.scrollContainer) {
2316
+ if (!isZoomGesture || !this.scrollContainer) {
1816
2317
  this.updateMousePosition();
1817
2318
  return;
1818
2319
  }
@@ -1832,8 +2333,95 @@ export class CalendarViewElement extends LitElement {
1832
2333
  const newOriginY = this.zoomOriginY * scaleRatio;
1833
2334
  const newScrollTop = newOriginY - this.zoomViewportY;
1834
2335
 
1835
- this.setView(newHeight, newScrollTop);
2336
+ // Avoid DOM scroll sync during wheel zoom to prevent scroll/zoom race flicker.
2337
+ this.setView(newHeight, newScrollTop, false);
1836
2338
  this.zoomOriginY = newOriginY;
2339
+ if (this.wheelZoomSyncTimeout) {
2340
+ clearTimeout(this.wheelZoomSyncTimeout);
2341
+ }
2342
+ this.wheelZoomSyncTimeout = setTimeout(() => {
2343
+ this.syncScrollDomToState();
2344
+ this.wheelZoomSyncTimeout = null;
2345
+ }, 80);
2346
+ };
2347
+
2348
+ getTouchDistance(touches: TouchList): number {
2349
+ if (touches.length < 2) return 0;
2350
+ const first = touches[0];
2351
+ const second = touches[1];
2352
+ if (!first || !second) return 0;
2353
+ return Math.hypot(
2354
+ second.clientX - first.clientX,
2355
+ second.clientY - first.clientY,
2356
+ );
2357
+ }
2358
+
2359
+ getTouchCenterY(touches: TouchList): number {
2360
+ if (touches.length < 2) return 0;
2361
+ const first = touches[0];
2362
+ const second = touches[1];
2363
+ if (!first || !second || !this.scrollContainer) return 0;
2364
+ const rect = this.scrollContainer.getBoundingClientRect();
2365
+ return (first.clientY + second.clientY) / 2 - rect.top;
2366
+ }
2367
+
2368
+ onTouchStart = (e: TouchEvent): void => {
2369
+ if (this.hasAttribute("scroll-lock") || !this.scrollContainer) return;
2370
+ if (e.touches.length < 2) return;
2371
+
2372
+ if (this.scrollAnimationFrame) {
2373
+ this.cancelScrollAnimation();
2374
+ }
2375
+
2376
+ const distance = this.getTouchDistance(e.touches);
2377
+ if (distance <= 0) return;
2378
+
2379
+ e.preventDefault();
2380
+ this.isPinchZooming = true;
2381
+ this.pinchStartDistance = distance;
2382
+ this.pinchStartHeight = this.dayHeight;
2383
+ this.zoomViewportY = this.getTouchCenterY(e.touches);
2384
+ this.pinchStartOriginY = this.zoomViewportY + this.scrollTop;
2385
+ this.zoomOriginY = this.pinchStartOriginY;
2386
+
2387
+ if (this.isFiltered && this.historyIndex === 0) {
2388
+ this.replaceFilterInHistory();
2389
+ } else {
2390
+ this.debouncedSaveToHistory();
2391
+ }
2392
+ };
2393
+
2394
+ onTouchMove = (e: TouchEvent): void => {
2395
+ if (!this.isPinchZooming || !this.scrollContainer) return;
2396
+ if (e.touches.length < 2 || this.pinchStartDistance <= 0) return;
2397
+
2398
+ e.preventDefault();
2399
+ const distance = this.getTouchDistance(e.touches);
2400
+ if (distance <= 0) return;
2401
+
2402
+ this.zoomViewportY = this.getTouchCenterY(e.touches);
2403
+ const scaleRatio = distance / this.pinchStartDistance;
2404
+ const newHeight = Math.max(
2405
+ MIN_DAY_HEIGHT,
2406
+ Math.min(MAX_DAY_HEIGHT, this.pinchStartHeight * scaleRatio),
2407
+ );
2408
+ const zoomRatio = newHeight / this.pinchStartHeight;
2409
+ const newOriginY = this.pinchStartOriginY * zoomRatio;
2410
+ const newScrollTop = newOriginY - this.zoomViewportY;
2411
+
2412
+ this.setView(newHeight, newScrollTop, false, false);
2413
+ this.zoomOriginY = newOriginY;
2414
+ };
2415
+
2416
+ onTouchEnd = (e: TouchEvent): void => {
2417
+ if (e.touches.length >= 2) {
2418
+ this.onTouchStart(e);
2419
+ return;
2420
+ }
2421
+
2422
+ this.isPinchZooming = false;
2423
+ this.pinchStartDistance = 0;
2424
+ this.syncScrollDomToState();
1837
2425
  };
1838
2426
 
1839
2427
  lastPointerY = 0;
@@ -1970,7 +2558,7 @@ export class CalendarViewElement extends LitElement {
1970
2558
  this.scrollContainer
1971
2559
  ) {
1972
2560
  const rect = this.scrollContainer.getBoundingClientRect();
1973
- const currentX = e.clientX - rect.left;
2561
+ const currentX = this.getContentXFromClientX(e.clientX);
1974
2562
  const currentY = e.clientY - rect.top + this.scrollTop;
1975
2563
 
1976
2564
  // Start selection
@@ -1992,7 +2580,7 @@ export class CalendarViewElement extends LitElement {
1992
2580
  this.selection = {
1993
2581
  startX: this.selectionStartX,
1994
2582
  startY: this.selectionStartY,
1995
- endX: e.clientX - rect.left,
2583
+ endX: this.getContentXFromClientX(e.clientX),
1996
2584
  endY: e.clientY - rect.top + this.scrollTop,
1997
2585
  };
1998
2586
  this.requestUpdate();
@@ -2001,7 +2589,7 @@ export class CalendarViewElement extends LitElement {
2001
2589
  // Event creation drag
2002
2590
  if (this.eventCreationStart && this.scrollContainer) {
2003
2591
  const rect = this.scrollContainer.getBoundingClientRect();
2004
- const currentX = e.clientX - rect.left;
2592
+ const currentX = this.getContentXFromClientX(e.clientX);
2005
2593
  const currentY = e.clientY - rect.top + this.scrollTop;
2006
2594
  this.isCreatingEvent = true;
2007
2595
 
@@ -2043,7 +2631,6 @@ export class CalendarViewElement extends LitElement {
2043
2631
  this.eventCreationEnd = { x: currentX, y: currentY };
2044
2632
  this.eventCreationPreviousShiftPressed = e.shiftKey;
2045
2633
  this.renderDateLabels();
2046
- this.renderEventCreationPreview();
2047
2634
  }
2048
2635
 
2049
2636
  // Event move drag - now handled by HTML5 drag and drop
@@ -2055,7 +2642,7 @@ export class CalendarViewElement extends LitElement {
2055
2642
  // Event resize drag
2056
2643
  if (this.resizingEvent && this.resizingEdge && this.scrollContainer) {
2057
2644
  const rect = this.scrollContainer.getBoundingClientRect();
2058
- const currentX = e.clientX - rect.left;
2645
+ const currentX = this.getContentXFromClientX(e.clientX);
2059
2646
  const currentY = e.clientY - rect.top + this.scrollTop;
2060
2647
  this.isResizingEvent = true;
2061
2648
 
@@ -2109,14 +2696,7 @@ export class CalendarViewElement extends LitElement {
2109
2696
  }
2110
2697
  if (this.isDraggingZoom) {
2111
2698
  this.isDraggingZoom = false;
2112
- // Sync scroll position with DOM now that drag is complete
2113
- if (this.scrollContainer && this.scrollContent) {
2114
- if (this.scrollContainer.scrollHeight < this._scrollTop) {
2115
- this.scrollContent.style.minHeight =
2116
- this._scrollTop + window.innerHeight + "px";
2117
- }
2118
- this.scrollContainer.scrollTop = this._scrollTop;
2119
- }
2699
+ this.syncScrollDomToState();
2120
2700
  }
2121
2701
  if (this.isDraggingMinimap) {
2122
2702
  this.isDraggingMinimap = false;
@@ -2188,7 +2768,11 @@ export class CalendarViewElement extends LitElement {
2188
2768
  const isZoomedOut = this.dayHeight < TIME_SCALE_DAY_HEIGHT;
2189
2769
  this.dispatchEvent(
2190
2770
  new CustomEvent("create-event", {
2191
- detail: { start: snappedStart, end: snappedEnd, isAllDay: isZoomedOut },
2771
+ detail: {
2772
+ start: snappedStart,
2773
+ end: snappedEnd,
2774
+ isAllDay: isZoomedOut,
2775
+ },
2192
2776
  bubbles: true,
2193
2777
  }),
2194
2778
  );
@@ -2254,6 +2838,9 @@ export class CalendarViewElement extends LitElement {
2254
2838
  this.resizingOriginalStart = null;
2255
2839
  this.resizingOriginalEnd = null;
2256
2840
  this.isResizingEvent = false;
2841
+ if (this.scrollContainer) {
2842
+ this.scrollContainer.style.cursor = "";
2843
+ }
2257
2844
  this.renderCanvas();
2258
2845
  this.requestUpdate();
2259
2846
  }
@@ -2273,6 +2860,7 @@ export class CalendarViewElement extends LitElement {
2273
2860
  // Close event detail overlay
2274
2861
  this.selectedEventForDetail = null;
2275
2862
  this.selectedEventRect = null;
2863
+ this.clearDescriptionSummary();
2276
2864
 
2277
2865
  if (hadSelection) {
2278
2866
  this.dispatchEvent(
@@ -2323,6 +2911,7 @@ export class CalendarViewElement extends LitElement {
2323
2911
  this.internal.clearSelection();
2324
2912
  this.selectedEventForDetail = null;
2325
2913
  this.selectedEventRect = null;
2914
+ this.clearDescriptionSummary();
2326
2915
  this.dispatchEvent(
2327
2916
  new CustomEvent("selection-change", {
2328
2917
  detail: { selectedEvents: [] },
@@ -2355,6 +2944,7 @@ export class CalendarViewElement extends LitElement {
2355
2944
  this.selectedEventForDetail = null;
2356
2945
  this.selectedEventRect = null;
2357
2946
  this.isDescriptionExpanded = false;
2947
+ this.clearDescriptionSummary();
2358
2948
  this.renderCanvas();
2359
2949
  this.requestUpdate();
2360
2950
  this.dispatchEvent(
@@ -2370,22 +2960,26 @@ export class CalendarViewElement extends LitElement {
2370
2960
  if (!this.scrollContainer || this.isDraggingZoom) return;
2371
2961
 
2372
2962
  const rect = this.scrollContainer.getBoundingClientRect();
2373
- const x = e.clientX - rect.left;
2963
+ const viewportX = e.clientX - rect.left;
2964
+ const contentX = this.getContentXFromClientX(e.clientX);
2374
2965
  const y = e.clientY - rect.top + this.scrollTop;
2375
2966
 
2376
2967
  // Update cursor position for status bar
2377
- this.cursorPosition = { x, y };
2968
+ this.cursorPosition = { x: contentX, y };
2378
2969
 
2379
2970
  // Check for resize handles first — suppressed when alt key is active
2380
- const resizeHandle = !e.altKey ? this.getResizeHandle(x, y) : null;
2971
+ const resizeHandle = !e.altKey ? this.getResizeHandle(viewportX, y) : null;
2381
2972
  if (resizeHandle && !this.isResizingEvent && !this.isDraggingEvent) {
2382
- this.scrollContainer.style.cursor = "ns-resize";
2973
+ this.scrollContainer.style.cursor =
2974
+ this.dayHeight >= TIME_SCALE_DAY_HEIGHT ? "ns-resize" : "ew-resize";
2383
2975
  } else if (!this.isResizingEvent && !this.isDraggingEvent) {
2384
2976
  this.scrollContainer.style.cursor = "";
2385
2977
  }
2386
2978
 
2387
2979
  // Check for event hover — suppressed when alt key is active
2388
- const hoveredEvent = !e.altKey ? this.getEventAtPosition(x, y) : null;
2980
+ const hoveredEvent = !e.altKey
2981
+ ? this.getEventAtPosition(viewportX, y)
2982
+ : null;
2389
2983
  const newHoveredId = hoveredEvent ? hoveredEvent.id : null;
2390
2984
 
2391
2985
  if (newHoveredId !== this.hoveredEventId) {
@@ -2395,7 +2989,7 @@ export class CalendarViewElement extends LitElement {
2395
2989
 
2396
2990
  this.requestUpdate();
2397
2991
 
2398
- if (x < LEFT_GUTTER_WIDTH) {
2992
+ if (viewportX < LEFT_GUTTER_WIDTH) {
2399
2993
  this.scrollContainer.classList.add("zoom-cursor");
2400
2994
  } else {
2401
2995
  this.scrollContainer.classList.remove("zoom-cursor");
@@ -2440,7 +3034,7 @@ export class CalendarViewElement extends LitElement {
2440
3034
  // Update visual preview
2441
3035
  if (this.scrollContainer) {
2442
3036
  const rect = this.scrollContainer.getBoundingClientRect();
2443
- const x = e.clientX - rect.left;
3037
+ const x = this.getContentXFromClientX(e.clientX);
2444
3038
  const y = e.clientY - rect.top + this.scrollTop;
2445
3039
  this.movingEventEnd = { x, y };
2446
3040
  this.renderCanvas(); // Re-render canvas with preview
@@ -2484,7 +3078,7 @@ export class CalendarViewElement extends LitElement {
2484
3078
  ) {
2485
3079
  // Handle internal event drop
2486
3080
  const rect = this.scrollContainer.getBoundingClientRect();
2487
- const dropX = e.clientX - rect.left;
3081
+ const dropX = this.getContentXFromClientX(e.clientX);
2488
3082
  const dropY = e.clientY - rect.top + this.scrollTop;
2489
3083
 
2490
3084
  const originDate = this.convertPositionToDateTime(
@@ -2518,11 +3112,10 @@ export class CalendarViewElement extends LitElement {
2518
3112
  } else {
2519
3113
  // Move event
2520
3114
  this.dispatchEvent(
2521
- new CustomEvent("move-event", {
3115
+ new CustomEvent("update-event", {
2522
3116
  detail: {
2523
3117
  event: this.movingEvent,
2524
- start: newStart,
2525
- end: newEnd,
3118
+ updates: { start: newStart, end: newEnd },
2526
3119
  },
2527
3120
  bubbles: true,
2528
3121
  }),
@@ -2545,7 +3138,12 @@ export class CalendarViewElement extends LitElement {
2545
3138
 
2546
3139
  onKeyDown = (e: KeyboardEvent): void => {
2547
3140
  const focused = e.composedPath()[0] as HTMLElement;
2548
- if (focused?.tagName === "INPUT" || focused?.tagName === "TEXTAREA" || focused?.isContentEditable) return;
3141
+ if (
3142
+ focused?.tagName === "INPUT" ||
3143
+ focused?.tagName === "TEXTAREA" ||
3144
+ focused?.isContentEditable
3145
+ )
3146
+ return;
2549
3147
  if (e.altKey && !this.altKeyActive) {
2550
3148
  this.altKeyActive = true;
2551
3149
  this.dispatchEvent(new CustomEvent("meta-key-change", { bubbles: true }));
@@ -2561,7 +3159,12 @@ export class CalendarViewElement extends LitElement {
2561
3159
 
2562
3160
  onKeyUp = (e: KeyboardEvent): void => {
2563
3161
  const focused = e.composedPath()[0] as HTMLElement;
2564
- if (focused?.tagName === "INPUT" || focused?.tagName === "TEXTAREA" || focused?.isContentEditable) return;
3162
+ if (
3163
+ focused?.tagName === "INPUT" ||
3164
+ focused?.tagName === "TEXTAREA" ||
3165
+ focused?.isContentEditable
3166
+ )
3167
+ return;
2565
3168
  if (!e.altKey && this.altKeyActive) {
2566
3169
  this.altKeyActive = false;
2567
3170
  this.dispatchEvent(new CustomEvent("meta-key-change", { bubbles: true }));
@@ -2682,6 +3285,7 @@ export class CalendarViewElement extends LitElement {
2682
3285
  y: number,
2683
3286
  ): { event: CalendarEvent; edge: "start" | "end" } | null {
2684
3287
  const RESIZE_HANDLE_SIZE = 8; // pixels from edge to detect resize
3288
+ const isZoomedIn = this.dayHeight >= TIME_SCALE_DAY_HEIGHT;
2685
3289
 
2686
3290
  // Check in reverse order (top to bottom rendering)
2687
3291
  for (let i = this.eventRects.length - 1; i >= 0; i--) {
@@ -2691,18 +3295,33 @@ export class CalendarViewElement extends LitElement {
2691
3295
  // Skip read-only events
2692
3296
  if (rect.event.readOnly) continue;
2693
3297
 
2694
- // Check if within horizontal bounds
2695
- if (x < rect.x || x > rect.x + rect.width) continue;
3298
+ if (isZoomedIn) {
3299
+ // In time-scale view, resize vertically from the top/bottom edges.
3300
+ if (x < rect.x || x > rect.x + rect.width) continue;
3301
+
3302
+ if (y >= rect.y && y <= rect.y + RESIZE_HANDLE_SIZE) {
3303
+ return { event: rect.event, edge: "start" };
3304
+ }
3305
+
3306
+ if (
3307
+ y >= rect.y + rect.height - RESIZE_HANDLE_SIZE &&
3308
+ y <= rect.y + rect.height
3309
+ ) {
3310
+ return { event: rect.event, edge: "end" };
3311
+ }
3312
+ continue;
3313
+ }
2696
3314
 
2697
- // Check top edge (resize start)
2698
- if (y >= rect.y && y <= rect.y + RESIZE_HANDLE_SIZE) {
3315
+ // In zoomed-out view, resize horizontally from the left/right edges.
3316
+ if (y < rect.y || y > rect.y + rect.height) continue;
3317
+
3318
+ if (x >= rect.x && x <= rect.x + RESIZE_HANDLE_SIZE) {
2699
3319
  return { event: rect.event, edge: "start" };
2700
3320
  }
2701
3321
 
2702
- // Check bottom edge (resize end)
2703
3322
  if (
2704
- y >= rect.y + rect.height - RESIZE_HANDLE_SIZE &&
2705
- y <= rect.y + rect.height
3323
+ x >= rect.x + rect.width - RESIZE_HANDLE_SIZE &&
3324
+ x <= rect.x + rect.width
2706
3325
  ) {
2707
3326
  return { event: rect.event, edge: "end" };
2708
3327
  }
@@ -2725,29 +3344,34 @@ export class CalendarViewElement extends LitElement {
2725
3344
  }
2726
3345
 
2727
3346
  const rect = this.scrollContainer.getBoundingClientRect();
2728
- const x = e.clientX - rect.left;
3347
+ const viewportX = e.clientX - rect.left;
3348
+ const x = this.getContentXFromClientX(e.clientX);
2729
3349
  const y = e.clientY - rect.top + this.scrollTop;
2730
3350
 
2731
3351
  // Check if clicking on zoom handle area (left gutter)
2732
- if (x < LEFT_GUTTER_WIDTH) {
3352
+ if (viewportX < LEFT_GUTTER_WIDTH) {
2733
3353
  this.onZoomHandleMouseDown(e);
2734
3354
  return;
2735
3355
  }
2736
3356
 
2737
3357
  // Check if clicking on a resize handle first
2738
- const resizeHandle = !e.altKey ? this.getResizeHandle(x, y) : null;
3358
+ const resizeHandle = !e.altKey ? this.getResizeHandle(viewportX, y) : null;
2739
3359
  if (resizeHandle) {
2740
3360
  this.resizingEvent = resizeHandle.event;
2741
3361
  this.resizingEdge = resizeHandle.edge;
2742
3362
  this.resizingOriginalStart = new Date(resizeHandle.event.start);
2743
3363
  this.resizingOriginalEnd = new Date(resizeHandle.event.end);
2744
3364
  this.isResizingEvent = false; // Will be set to true on first mouse move
3365
+ this.scrollContainer.style.cursor =
3366
+ this.dayHeight >= TIME_SCALE_DAY_HEIGHT ? "ns-resize" : "ew-resize";
2745
3367
  return;
2746
3368
  }
2747
3369
 
2748
3370
  // Check if clicking on an event — defer click vs drag to mouseup
2749
3371
  // Skip event interaction if Alt/Option is held (create new event instead)
2750
- const clickedEvent = !e.altKey ? this.getEventAtPosition(x, y) : null;
3372
+ const clickedEvent = !e.altKey
3373
+ ? this.getEventAtPosition(viewportX, y)
3374
+ : null;
2751
3375
  if (clickedEvent) {
2752
3376
  // Clear selection when starting to drag/move an event
2753
3377
  const hadSelection = this.internal.getSelectedEvents().length > 0;
@@ -2764,6 +3388,7 @@ export class CalendarViewElement extends LitElement {
2764
3388
  // Close event detail overlay
2765
3389
  this.selectedEventForDetail = null;
2766
3390
  this.selectedEventRect = null;
3391
+ this.clearDescriptionSummary();
2767
3392
 
2768
3393
  this.movingEvent = clickedEvent;
2769
3394
  this.movingEventOrigin = { x, y };
@@ -2779,6 +3404,7 @@ export class CalendarViewElement extends LitElement {
2779
3404
  // Close event detail overlay when clicking outside of events
2780
3405
  this.selectedEventForDetail = null;
2781
3406
  this.selectedEventRect = null;
3407
+ this.clearDescriptionSummary();
2782
3408
 
2783
3409
  // Clear selection when clicking on empty space
2784
3410
  const hadSelection = this.internal.getSelectedEvents().length > 0;
@@ -2837,22 +3463,20 @@ export class CalendarViewElement extends LitElement {
2837
3463
  const minY = Math.min(this.selection.startY, this.selection.endY);
2838
3464
  const maxY = Math.max(this.selection.startY, this.selection.endY);
2839
3465
 
2840
- const rect = this.scrollContainer.getBoundingClientRect();
2841
- const gridWidth = rect.width - LEFT_GUTTER_WIDTH;
2842
- const dayWidth = gridWidth / this._columnsPerRow;
3466
+ const dayWidth = this.getDayWidth();
2843
3467
 
2844
3468
  // Find column indices from X coordinates
2845
3469
  const startCol = Math.max(
2846
3470
  0,
2847
3471
  Math.min(
2848
- this._columnsPerRow - 1,
3472
+ this.columnsPerRow - 1,
2849
3473
  Math.floor((minX - LEFT_GUTTER_WIDTH) / dayWidth),
2850
3474
  ),
2851
3475
  );
2852
3476
  const endCol = Math.max(
2853
3477
  0,
2854
3478
  Math.min(
2855
- this._columnsPerRow - 1,
3479
+ this.columnsPerRow - 1,
2856
3480
  Math.floor((maxX - LEFT_GUTTER_WIDTH) / dayWidth),
2857
3481
  ),
2858
3482
  );
@@ -2957,7 +3581,12 @@ export class CalendarViewElement extends LitElement {
2957
3581
  if (event.visualStyle === "heatmap") continue;
2958
3582
 
2959
3583
  // Only select events from the active calendar
2960
- if (this.activeCalendarId && event.calendarId !== this.activeCalendarId && event.sourceId !== this.activeCalendarId) continue;
3584
+ if (
3585
+ this.activeCalendarId &&
3586
+ event.calendarId !== this.activeCalendarId &&
3587
+ event.sourceId !== this.activeCalendarId
3588
+ )
3589
+ continue;
2961
3590
 
2962
3591
  const eventStartTime = event.start.getTime();
2963
3592
  const eventEndTime = event.end.getTime();
@@ -2986,6 +3615,7 @@ export class CalendarViewElement extends LitElement {
2986
3615
  // Close event detail overlay when replacing selection
2987
3616
  this.selectedEventForDetail = null;
2988
3617
  this.selectedEventRect = null;
3618
+ this.clearDescriptionSummary();
2989
3619
  }
2990
3620
  for (const event of selectedEvents) {
2991
3621
  this.internal.selectEvent(event, "add");
@@ -3164,7 +3794,7 @@ export class CalendarViewElement extends LitElement {
3164
3794
 
3165
3795
  onZoomSliderChange = (e: Event): void => {
3166
3796
  const slider = e.target as HTMLInputElement;
3167
- const newHeight = parseInt(slider.value, 10);
3797
+ const newHeight = Number.parseInt(slider.value, 10);
3168
3798
  const oldHeight = this.dayHeight;
3169
3799
 
3170
3800
  if (!this.scrollContainer) {
@@ -3184,6 +3814,38 @@ export class CalendarViewElement extends LitElement {
3184
3814
  this.setView(newHeight, newScrollTop);
3185
3815
  };
3186
3816
 
3817
+ onDayColumnWidthSliderChange = (e: Event): void => {
3818
+ const slider = e.target as HTMLInputElement;
3819
+ const newWidth = Number.parseInt(slider.value, 10);
3820
+
3821
+ if (!this.scrollContainer) {
3822
+ this.minDayColumnWidth = newWidth;
3823
+ return;
3824
+ }
3825
+
3826
+ const oldGridWidth = this.getGridWidth();
3827
+ const viewportWidth = this.scrollContainer.clientWidth;
3828
+ const viewportCenterX = viewportWidth / 2;
3829
+ const contentCenterX = this.scrollLeft + viewportCenterX;
3830
+
3831
+ this.minDayColumnWidth = newWidth;
3832
+
3833
+ const newGridWidth = this.getGridWidth();
3834
+ const scaleRatio = newGridWidth / oldGridWidth;
3835
+ const newContentCenterX = contentCenterX * scaleRatio;
3836
+ const maxScrollLeft = Math.max(
3837
+ 0,
3838
+ LEFT_GUTTER_WIDTH + newGridWidth - viewportWidth,
3839
+ );
3840
+ const newScrollLeft = Math.max(
3841
+ 0,
3842
+ Math.min(maxScrollLeft, newContentCenterX - viewportCenterX),
3843
+ );
3844
+
3845
+ this.scrollContainer.scrollLeft = newScrollLeft;
3846
+ this.renderCanvas();
3847
+ };
3848
+
3187
3849
  async onEventClick(event: CalendarEvent, e: MouseEvent): Promise<void> {
3188
3850
  const isCmdOrCtrl = e.metaKey || e.ctrlKey;
3189
3851
 
@@ -3195,6 +3857,7 @@ export class CalendarViewElement extends LitElement {
3195
3857
 
3196
3858
  // Show event detail overlay for single selection
3197
3859
  if (!isCmdOrCtrl) {
3860
+ this.clearDescriptionSummary();
3198
3861
  this.selectedEventForDetail = event;
3199
3862
  this.isDescriptionExpanded = false;
3200
3863
  // Find the event's rect and store it for positioning
@@ -3214,6 +3877,7 @@ export class CalendarViewElement extends LitElement {
3214
3877
  this.selectedEventForDetail = null;
3215
3878
  this.selectedEventRect = null;
3216
3879
  this.isDescriptionExpanded = false;
3880
+ this.clearDescriptionSummary();
3217
3881
  this.requestUpdate();
3218
3882
  }
3219
3883
 
@@ -3275,6 +3939,7 @@ export class CalendarViewElement extends LitElement {
3275
3939
  // Close event detail overlay when clearing selection
3276
3940
  this.selectedEventForDetail = null;
3277
3941
  this.selectedEventRect = null;
3942
+ this.clearDescriptionSummary();
3278
3943
 
3279
3944
  if (hadSelection) {
3280
3945
  this.dispatchEvent(
@@ -3328,296 +3993,135 @@ export class CalendarViewElement extends LitElement {
3328
3993
  this.renderDateLabels();
3329
3994
  }
3330
3995
 
3331
- renderEventMovePreview(): void {
3332
- if (!this.movingEvent || !this.movingEventOrigin || !this.movingEventEnd)
3333
- return;
3334
-
3335
- const originDate = this.convertPositionToDateTime(
3336
- this.movingEventOrigin.x,
3337
- this.movingEventOrigin.y,
3338
- );
3339
- const currentDate = this.convertPositionToDateTime(
3340
- this.movingEventEnd.x,
3341
- this.movingEventEnd.y,
3342
- );
3343
- if (!originDate || !currentDate) return;
3996
+ getPreviewEvents(): PreviewEventData[] {
3997
+ const previews: PreviewEventData[] = [];
3344
3998
 
3345
- const deltaMs = currentDate.getTime() - originDate.getTime();
3346
- const snap15 = (d: Date) => {
3347
- d.setMinutes(Math.round(d.getMinutes() / 15) * 15, 0, 0);
3348
- return d;
3349
- };
3350
- const newStart = snap15(
3351
- new Date(this.movingEvent.start.getTime() + deltaMs),
3352
- );
3353
- const newEnd = snap15(new Date(this.movingEvent.end.getTime() + deltaMs));
3354
-
3355
- // The grab point (originDate) relative to the event's midpoint tells us
3356
- // which half the user grabbed — this is stable for the whole drag.
3357
- const eventMidMs = (this.movingEvent.start.getTime() + this.movingEvent.end.getTime()) / 2;
3358
- const useStartEdge = originDate.getTime() <= eventMidMs;
3359
-
3360
- if (this.movingEventDuplicateMode) {
3361
- const accent =
3362
- getComputedStyle(this).getPropertyValue("--accent-primary").trim() ||
3363
- "rgb(100, 150, 255)";
3364
- this.renderVirtualEvent(
3365
- newStart,
3366
- newEnd,
3367
- {
3368
- fill: accent.replace("rgb", "rgba").replace(")", ", 0.3)"),
3369
- stroke: accent.replace("rgb", "rgba").replace(")", ", 0.8)"),
3370
- text: "white",
3371
- dashed: false,
3372
- },
3373
- useStartEdge,
3374
- );
3375
- } else {
3376
- const rgb = hexToRgb(this.movingEvent.color || "#888888");
3377
- this.renderVirtualEvent(
3378
- newStart,
3379
- newEnd,
3380
- {
3381
- fill: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.5)`,
3382
- stroke: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.5)`,
3383
- text: "rgba(255, 255, 255, 0.5)",
3384
- dashed: false,
3385
- },
3386
- useStartEdge,
3999
+ if (this.movingEvent && this.movingEventOrigin && this.movingEventEnd) {
4000
+ const originDate = this.convertPositionToDateTime(
4001
+ this.movingEventOrigin.x,
4002
+ this.movingEventOrigin.y,
3387
4003
  );
3388
- }
3389
- }
3390
-
3391
- renderEventCreationPreview(): void {
3392
- if (!this.eventCreationStart || !this.eventCreationEnd) return;
3393
-
3394
- const startDate = this.convertPositionToDateTime(
3395
- this.eventCreationStart.x,
3396
- this.eventCreationStart.y,
3397
- );
3398
- const endDate = this.convertPositionToDateTime(
3399
- this.eventCreationEnd.x,
3400
- this.eventCreationEnd.y,
3401
- );
3402
- if (!startDate || !endDate) return;
3403
-
3404
- let earlier: Date;
3405
- let later: Date;
3406
-
3407
- if (
3408
- this.eventCreationShiftPressed &&
3409
- this.eventCreationInitialDuration !== null
3410
- ) {
3411
- // Shift pressed: move event with fixed duration to current mouse position
3412
4004
  const currentDate = this.convertPositionToDateTime(
3413
- this.eventCreationEnd.x,
3414
- this.eventCreationEnd.y,
3415
- );
3416
- if (!currentDate) return;
3417
-
3418
- // Position the event so it ends at the current mouse position
3419
- later = new Date(currentDate);
3420
- earlier = new Date(
3421
- currentDate.getTime() - this.eventCreationInitialDuration,
4005
+ this.movingEventEnd.x,
4006
+ this.movingEventEnd.y,
3422
4007
  );
3423
- } else {
3424
- // Normal behavior: stretch from start to end
3425
- earlier = startDate < endDate ? startDate : endDate;
3426
- later = startDate < endDate ? endDate : startDate;
3427
- }
4008
+ if (originDate && currentDate) {
4009
+ const deltaMs = currentDate.getTime() - originDate.getTime();
4010
+ const snap15 = (d: Date) => {
4011
+ d.setMinutes(Math.round(d.getMinutes() / 15) * 15, 0, 0);
4012
+ return d;
4013
+ };
4014
+ const start = snap15(
4015
+ new Date(this.movingEvent.start.getTime() + deltaMs),
4016
+ );
4017
+ const end = snap15(new Date(this.movingEvent.end.getTime() + deltaMs));
4018
+ if (this.dayHeight < TIME_SCALE_EVENT_THRESHOLD) {
4019
+ start.setHours(0, 0, 0, 0);
4020
+ end.setHours(23, 59, 59, 999);
4021
+ }
3428
4022
 
3429
- if (this.dayHeight < TIME_SCALE_DAY_HEIGHT) {
3430
- earlier.setHours(0, 0, 0, 0);
3431
- later.setHours(23, 59, 59, 999);
3432
- } else {
3433
- earlier.setMinutes(Math.round(earlier.getMinutes() / 15) * 15, 0, 0);
3434
- later.setMinutes(Math.round(later.getMinutes() / 15) * 15, 0, 0);
4023
+ if (this.movingEventDuplicateMode) {
4024
+ const accent =
4025
+ getComputedStyle(this)
4026
+ .getPropertyValue("--accent-primary")
4027
+ .trim() || "rgb(100, 150, 255)";
4028
+ previews.push({
4029
+ id: `move-preview-${this.movingEvent.id}`,
4030
+ start,
4031
+ end,
4032
+ color: {
4033
+ fill: accent.replace("rgb", "rgba").replace(")", ", 0.3)"),
4034
+ stroke: accent.replace("rgb", "rgba").replace(")", ", 0.8)"),
4035
+ text: "white",
4036
+ dashed: false,
4037
+ },
4038
+ });
4039
+ } else {
4040
+ const rgb = hexToRgb(this.movingEvent.color || "#888888");
4041
+ previews.push({
4042
+ id: `move-preview-${this.movingEvent.id}`,
4043
+ start,
4044
+ end,
4045
+ excludeEventId: this.movingEvent.id,
4046
+ color: {
4047
+ fill: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.5)`,
4048
+ stroke: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.5)`,
4049
+ text: "rgba(255, 255, 255, 0.5)",
4050
+ dashed: false,
4051
+ },
4052
+ });
4053
+ }
4054
+ }
3435
4055
  }
3436
4056
 
3437
- // Use active calendar color if available, otherwise fall back to accent-primary
3438
- let fill: string;
3439
- let stroke: string;
3440
- if (this.activeCalendarColor) {
3441
- const rgb = hexToRgb(this.activeCalendarColor);
3442
- fill = `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.3)`;
3443
- stroke = `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.8)`;
3444
- } else {
3445
- const accent =
3446
- getComputedStyle(this).getPropertyValue("--accent-primary").trim() ||
3447
- "rgb(100, 150, 255)";
3448
- fill = accent.replace("rgb", "rgba").replace(")", ", 0.3)");
3449
- stroke = accent.replace("rgb", "rgba").replace(")", ", 0.8)");
3450
- }
3451
- // In shift mode the cursor tracks the end; otherwise it tracks whichever
3452
- // edge is away from the fixed anchor (eventCreationStart).
3453
- const useStartEdge = this.eventCreationShiftPressed
3454
- ? false
3455
- : this.eventCreationEnd.y <= this.eventCreationStart.y;
3456
-
3457
- this.renderVirtualEvent(earlier, later, { fill, stroke, text: "white" }, useStartEdge);
3458
- }
3459
-
3460
- renderVirtualEvent(
3461
- start: Date,
3462
- end: Date,
3463
- color: { fill: string; stroke: string; text: string; dashed?: boolean },
3464
- useStartEdge = true,
3465
- ): void {
3466
- if (!this.overlayCanvas || !this.overlayCtx || !this.scrollContainer)
3467
- return;
3468
-
3469
- const ctx = this.overlayCtx;
3470
- const fontFamily = getComputedStyle(this).fontFamily;
3471
- const scrollRect = this.scrollContainer.getBoundingClientRect();
3472
- const gridWidth = scrollRect.width - LEFT_GUTTER_WIDTH - MINIMAP_WIDTH;
3473
- const dayWidth = gridWidth / this._columnsPerRow;
3474
-
3475
- const getTimeY = (day: Date, hours: number, minutes: number) => {
3476
- const week = this.weeks.find((w) =>
3477
- w.days.some((d) => d.toDateString() === day.toDateString()),
3478
- );
3479
- if (!week) return null;
3480
- const dayIndex = week.days.findIndex(
3481
- (d) => d.toDateString() === day.toDateString(),
3482
- );
3483
- if (dayIndex < 0) return null;
3484
- const { row } = this.getDayVisualPosition(dayIndex);
3485
- const totalMinutes = hours * 60 + minutes;
3486
- const rowY = week.yOffset + row * this.dayHeight;
3487
- return rowY + (totalMinutes / 1440) * this.dayHeight - this.scrollTop;
3488
- };
3489
-
3490
- const getDayColumnX = (day: Date) => {
3491
- const week = this.weeks.find((w) =>
3492
- w.days.some((d) => d.toDateString() === day.toDateString()),
4057
+ if (this.eventCreationStart && this.eventCreationEnd) {
4058
+ const startDate = this.convertPositionToDateTime(
4059
+ this.eventCreationStart.x,
4060
+ this.eventCreationStart.y,
3493
4061
  );
3494
- if (!week) return null;
3495
- const dayIndex = week.days.findIndex(
3496
- (d) => d.toDateString() === day.toDateString(),
4062
+ const endDate = this.convertPositionToDateTime(
4063
+ this.eventCreationEnd.x,
4064
+ this.eventCreationEnd.y,
3497
4065
  );
3498
- if (dayIndex < 0) return null;
3499
- const { col } = this.getDayVisualPosition(dayIndex);
3500
- return col * dayWidth;
3501
- };
3502
-
3503
- const drawBlock = (colX: number, top: number, bottom: number) => {
3504
- const bx = colX + 2;
3505
- const bw = dayWidth - 4;
3506
- const bh = Math.max(4, bottom - top);
3507
- ctx.fillStyle = color.fill;
3508
- ctx.beginPath();
3509
- ctx.roundRect(bx, top, bw, bh, 4);
3510
- ctx.fill();
3511
- ctx.strokeStyle = color.stroke;
3512
- ctx.lineWidth = 1;
3513
- if (color.dashed !== false) ctx.setLineDash([6, 3]);
3514
- ctx.stroke();
3515
- ctx.setLineDash([]);
3516
- };
3517
-
3518
- const sameDay = start.toDateString() === end.toDateString();
3519
-
3520
- if (sameDay) {
3521
- const colX = getDayColumnX(start);
3522
- const top = getTimeY(start, start.getHours(), start.getMinutes());
3523
- const bottom = getTimeY(end, end.getHours(), end.getMinutes());
3524
- if (colX != null && top != null && bottom != null)
3525
- drawBlock(colX, top, bottom);
3526
- } else {
3527
- const current = new Date(start);
3528
- current.setHours(0, 0, 0, 0);
3529
- const endDay = new Date(end);
3530
- endDay.setHours(23, 59, 59, 999);
3531
- while (current <= endDay) {
3532
- const colX = getDayColumnX(current);
3533
- if (colX != null) {
3534
- const isFirst = current.toDateString() === start.toDateString();
3535
- const isLast = current.toDateString() === end.toDateString();
3536
- let top: number | null;
3537
- let bottom: number | null;
3538
- if (isFirst) {
3539
- top = getTimeY(current, start.getHours(), start.getMinutes());
3540
- bottom = getTimeY(current, 23, 59);
3541
- } else if (isLast) {
3542
- top = getTimeY(current, 0, 0);
3543
- bottom = getTimeY(current, end.getHours(), end.getMinutes());
4066
+ if (startDate && endDate) {
4067
+ let earlier: Date;
4068
+ let later: Date;
4069
+
4070
+ if (
4071
+ this.eventCreationShiftPressed &&
4072
+ this.eventCreationInitialDuration !== null
4073
+ ) {
4074
+ const currentDate = this.convertPositionToDateTime(
4075
+ this.eventCreationEnd.x,
4076
+ this.eventCreationEnd.y,
4077
+ );
4078
+ if (currentDate) {
4079
+ later = new Date(currentDate);
4080
+ earlier = new Date(
4081
+ currentDate.getTime() - this.eventCreationInitialDuration,
4082
+ );
3544
4083
  } else {
3545
- top = getTimeY(current, 0, 0);
3546
- bottom = getTimeY(current, 23, 59);
4084
+ earlier = startDate < endDate ? startDate : endDate;
4085
+ later = startDate < endDate ? endDate : startDate;
3547
4086
  }
3548
- if (top != null && bottom != null) drawBlock(colX, top, bottom);
4087
+ } else {
4088
+ earlier = startDate < endDate ? startDate : endDate;
4089
+ later = startDate < endDate ? endDate : startDate;
3549
4090
  }
3550
- current.setDate(current.getDate() + 1);
3551
- }
3552
- }
3553
-
3554
- // Time label
3555
- const fmtTime = (d: Date) =>
3556
- `${d.getHours().toString().padStart(2, "0")}:${d
3557
- .getMinutes()
3558
- .toString()
3559
- .padStart(2, "0")}`;
3560
- const fmtDate = (d: Date) => `${d.getDate()}.${d.getMonth() + 1}`;
3561
- const label = sameDay
3562
- ? `${fmtTime(start)} – ${fmtTime(end)}`
3563
- : `${fmtDate(start)} ${fmtTime(start)} – ${fmtDate(end)} ${fmtTime(end)}`;
3564
-
3565
- const durationMs = Math.abs(end.getTime() - start.getTime());
3566
- const durationMinutes = durationMs / (1000 * 60);
3567
- const isShortEvent = durationMinutes < 15;
3568
4091
 
3569
- const firstColX = getDayColumnX(start);
3570
- const lastColX = getDayColumnX(end);
3571
- const startY = getTimeY(start, start.getHours(), start.getMinutes());
3572
- const endY = getTimeY(end, end.getHours(), end.getMinutes());
3573
-
3574
- if (
3575
- firstColX != null &&
3576
- lastColX != null &&
3577
- startY != null &&
3578
- endY != null
3579
- ) {
3580
- ctx.font = `600 11px ${fontFamily}`;
3581
- ctx.fillStyle = color.text;
3582
- ctx.textAlign = "left";
4092
+ if (this.dayHeight < TIME_SCALE_EVENT_THRESHOLD) {
4093
+ earlier.setHours(0, 0, 0, 0);
4094
+ later.setHours(23, 59, 59, 999);
4095
+ } else {
4096
+ earlier.setMinutes(Math.round(earlier.getMinutes() / 15) * 15, 0, 0);
4097
+ later.setMinutes(Math.round(later.getMinutes() / 15) * 15, 0, 0);
4098
+ }
3583
4099
 
3584
- // Use appropriate column based on which edge is closer
3585
- const labelColX = useStartEdge ? firstColX : lastColX;
3586
-
3587
- if (isShortEvent) {
3588
- // For short events, display label below the event on the edge closest to cursor
3589
- const labelY = useStartEdge ? startY : endY;
3590
- const labelBgY = labelY + 6;
3591
- const labelTextY = labelY + 10;
3592
-
3593
- // Draw background pill
3594
- const textWidth = ctx.measureText(label).width;
3595
- const bgPaddingX = 6;
3596
- const bgElevated =
3597
- getComputedStyle(this).getPropertyValue("--bg-elevated").trim() ||
3598
- "rgba(0, 0, 0, 0.8)";
3599
- ctx.fillStyle = bgElevated;
3600
- ctx.beginPath();
3601
- ctx.roundRect(
3602
- labelColX + 4,
3603
- labelBgY,
3604
- textWidth + bgPaddingX * 2,
3605
- 16,
3606
- 4,
3607
- );
3608
- ctx.fill();
4100
+ let fill: string;
4101
+ let stroke: string;
4102
+ if (this.activeCalendarColor) {
4103
+ const rgb = hexToRgb(this.activeCalendarColor);
4104
+ fill = `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.3)`;
4105
+ stroke = `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.8)`;
4106
+ } else {
4107
+ const accent =
4108
+ getComputedStyle(this)
4109
+ .getPropertyValue("--accent-primary")
4110
+ .trim() || "rgb(100, 150, 255)";
4111
+ fill = accent.replace("rgb", "rgba").replace(")", ", 0.3)");
4112
+ stroke = accent.replace("rgb", "rgba").replace(")", ", 0.8)");
4113
+ }
3609
4114
 
3610
- // Draw text
3611
- ctx.fillStyle = "white";
3612
- ctx.textBaseline = "top";
3613
- ctx.fillText(label, labelColX + 4 + bgPaddingX, labelTextY);
3614
- } else {
3615
- // For longer events, display label inside the event on the edge closest to cursor
3616
- ctx.textBaseline = "top";
3617
- const labelY = useStartEdge ? startY + 4 : endY - 18;
3618
- ctx.fillText(label, labelColX + 8, labelY);
4115
+ previews.push({
4116
+ id: "creation-preview",
4117
+ start: earlier,
4118
+ end: later,
4119
+ color: { fill, stroke, text: "white", dashed: true },
4120
+ });
3619
4121
  }
3620
4122
  }
4123
+
4124
+ return previews;
3621
4125
  }
3622
4126
 
3623
4127
  renderDateLabels(): void {
@@ -3630,11 +4134,12 @@ export class CalendarViewElement extends LitElement {
3630
4134
 
3631
4135
  ctx.clearRect(0, 0, width, height);
3632
4136
 
3633
- const dayWidth = width / this._columnsPerRow;
4137
+ const dayWidth = this.getDayWidth();
3634
4138
  const scrollTop = this.scrollTop;
4139
+ const scrollLeft = this.scrollLeft;
3635
4140
  const fontFamily = getComputedStyle(this).fontFamily;
3636
4141
 
3637
- ctx.font = `600 10px ${fontFamily}`;
4142
+ ctx.font = `600 12px ${fontFamily}`;
3638
4143
  const textSecondary =
3639
4144
  getComputedStyle(this).getPropertyValue("--text-secondary").trim() ||
3640
4145
  "rgba(255, 255, 255, 0.6)";
@@ -3652,7 +4157,7 @@ export class CalendarViewElement extends LitElement {
3652
4157
  if (!day) continue;
3653
4158
 
3654
4159
  const { row, col } = this.getDayVisualPosition(dayIndex);
3655
- const x = col * dayWidth;
4160
+ const x = col * dayWidth - scrollLeft;
3656
4161
  const dayTop = week.yOffset + row * this.dayHeight - scrollTop;
3657
4162
  const dayBottom = dayTop + this.dayHeight;
3658
4163
 
@@ -3691,6 +4196,8 @@ export class CalendarViewElement extends LitElement {
3691
4196
  }
3692
4197
  }
3693
4198
  }
4199
+
4200
+ this.renderMonthLabels(ctx, width, height, fontFamily);
3694
4201
  }
3695
4202
 
3696
4203
  renderWeekdayLabels(
@@ -3711,57 +4218,41 @@ export class CalendarViewElement extends LitElement {
3711
4218
  getComputedStyle(this).getPropertyValue("--bg-primary").trim() ||
3712
4219
  "rgba(30, 30, 30, 0.9)";
3713
4220
 
3714
- ctx.font = `500 12px ${fontFamily}`;
4221
+ ctx.font = `600 12px ${fontFamily}`;
3715
4222
  ctx.textAlign = "center";
3716
4223
  ctx.textBaseline = "top";
3717
4224
 
3718
4225
  const labelHeight = 16;
3719
- const labelY = 12; // Below month label
3720
-
3721
- // Find the first visible visual row
3722
- const firstWeek = visibleWeeks[0];
3723
- if (!firstWeek) return;
3724
-
3725
- // Determine which visual rows are visible
3726
- for (let row = 0; row < this.rowsPerWeek; row++) {
3727
- const rowTop = firstWeek.yOffset + row * this.dayHeight - scrollTop;
3728
- const rowBottom = rowTop + this.dayHeight;
3729
-
3730
- // Check if this visual row is visible
3731
- if (rowBottom < 0 || rowTop > height) continue;
4226
+ const labelY = 48; // Below sticky month label
3732
4227
 
3733
- // Calculate sticky Y position - stays at top but doesn't go past row bottom
3734
- const stickyY = Math.min(labelY, rowBottom - labelHeight - 2);
3735
- if (stickyY < 0) continue;
4228
+ const stickyY = labelY;
3736
4229
 
3737
- // Draw weekday labels for each column in this visual row
3738
- for (let col = 0; col < this._columnsPerRow; col++) {
3739
- const dayIndex = row * this._columnsPerRow + col;
3740
- if (dayIndex >= 7) continue;
3741
- const dayName = weekdayNames[dayIndex];
3742
- if (!dayName) continue;
4230
+ for (let dayIndex = 0; dayIndex < 7; dayIndex++) {
4231
+ const dayName = weekdayNames[dayIndex];
4232
+ if (!dayName) continue;
3743
4233
 
3744
- const x = LEFT_GUTTER_WIDTH + col * dayWidth + dayWidth / 2;
4234
+ const x =
4235
+ LEFT_GUTTER_WIDTH +
4236
+ dayIndex * dayWidth +
4237
+ dayWidth / 2 -
4238
+ this.scrollLeft;
3745
4239
 
3746
- // Draw background pill
3747
- const textWidth = ctx.measureText(dayName).width;
3748
- const bgPaddingX = 6;
3749
- const bgPaddingY = 2;
3750
- ctx.fillStyle = bgPrimary;
3751
- ctx.beginPath();
3752
- ctx.roundRect(
3753
- x - textWidth / 2 - bgPaddingX,
3754
- stickyY,
3755
- textWidth + bgPaddingX * 2,
3756
- labelHeight,
3757
- 4,
3758
- );
3759
- ctx.fill();
4240
+ const textWidth = ctx.measureText(dayName).width;
4241
+ const bgPaddingX = 6;
4242
+ const bgPaddingY = 2;
4243
+ ctx.fillStyle = bgPrimary;
4244
+ ctx.beginPath();
4245
+ ctx.roundRect(
4246
+ x - textWidth / 2 - bgPaddingX,
4247
+ stickyY,
4248
+ textWidth + bgPaddingX * 2,
4249
+ labelHeight,
4250
+ 4,
4251
+ );
4252
+ ctx.fill();
3760
4253
 
3761
- // Draw text
3762
- ctx.fillStyle = textMuted;
3763
- ctx.fillText(dayName, x, stickyY + bgPaddingY + 1);
3764
- }
4254
+ ctx.fillStyle = textMuted;
4255
+ ctx.fillText(dayName, x, stickyY + bgPaddingY + 1);
3765
4256
  }
3766
4257
  }
3767
4258
 
@@ -3959,9 +4450,7 @@ export class CalendarViewElement extends LitElement {
3959
4450
  ): Date | null {
3960
4451
  if (!this.scrollContainer) return null;
3961
4452
 
3962
- const rect = this.scrollContainer.getBoundingClientRect();
3963
- const gridWidth = rect.width - LEFT_GUTTER_WIDTH;
3964
- const dayWidth = gridWidth / this._columnsPerRow;
4453
+ const dayWidth = this.getDayWidth();
3965
4454
 
3966
4455
  // Check if X is in the calendar grid area
3967
4456
  if (x < LEFT_GUTTER_WIDTH) return null;
@@ -3969,7 +4458,7 @@ export class CalendarViewElement extends LitElement {
3969
4458
  const col = Math.max(
3970
4459
  0,
3971
4460
  Math.min(
3972
- this._columnsPerRow - 1,
4461
+ this.columnsPerRow - 1,
3973
4462
  Math.floor((x - LEFT_GUTTER_WIDTH) / dayWidth),
3974
4463
  ),
3975
4464
  );
@@ -3983,7 +4472,7 @@ export class CalendarViewElement extends LitElement {
3983
4472
 
3984
4473
  // Calculate which visual row within the week
3985
4474
  const rowInWeek = Math.floor((y - week.yOffset) / this.dayHeight);
3986
- const dayIndex = rowInWeek * this._columnsPerRow + col;
4475
+ const dayIndex = rowInWeek * this.columnsPerRow + col;
3987
4476
 
3988
4477
  if (dayIndex < 0 || dayIndex > 6) return null;
3989
4478
 
@@ -4013,9 +4502,7 @@ export class CalendarViewElement extends LitElement {
4013
4502
  convertDateTimeToPosition(date: Date): { x: number; y: number } | null {
4014
4503
  if (!this.scrollContainer) return null;
4015
4504
 
4016
- const rect = this.scrollContainer.getBoundingClientRect();
4017
- const gridWidth = rect.width - LEFT_GUTTER_WIDTH;
4018
- const dayWidth = gridWidth / this._columnsPerRow;
4505
+ const dayWidth = this.getDayWidth();
4019
4506
 
4020
4507
  // Find the week that contains this date
4021
4508
  const dateStr = date.toDateString();
@@ -4122,6 +4609,7 @@ export class CalendarViewElement extends LitElement {
4122
4609
 
4123
4610
  this.selectedEventForDetail = null;
4124
4611
  this.selectedEventRect = null;
4612
+ this.clearDescriptionSummary();
4125
4613
  this.requestUpdate();
4126
4614
  }
4127
4615
 
@@ -4261,6 +4749,34 @@ export class CalendarViewElement extends LitElement {
4261
4749
  }
4262
4750
 
4263
4751
  const event = this.selectedEventForDetail;
4752
+ const eventDescription = sanitizeEventDescription(event.description ?? "");
4753
+ const eventDescriptionText = eventDescription.replaceAll("\\n", "\n");
4754
+ const useDescriptionSummary = eventDescription.trim().length > 200;
4755
+ const currentSummaryKey = this.getDescriptionSummaryTargetKey(event);
4756
+ const hasRequestedSummaryForCurrentEvent =
4757
+ this.requestedDescriptionSummaryKey === currentSummaryKey;
4758
+ const isSummaryForCurrentEvent =
4759
+ this.descriptionSummaryTargetKey === currentSummaryKey;
4760
+ const hasSummaryStateForCurrentEvent =
4761
+ hasRequestedSummaryForCurrentEvent || isSummaryForCurrentEvent;
4762
+ const streamedSummaryText = hasSummaryStateForCurrentEvent
4763
+ ? this.descriptionSummaryText
4764
+ : "";
4765
+ const isSummaryLoading =
4766
+ useDescriptionSummary &&
4767
+ hasSummaryStateForCurrentEvent &&
4768
+ this.descriptionSummaryLoading;
4769
+ const shouldRenderSummary =
4770
+ useDescriptionSummary && hasSummaryStateForCurrentEvent;
4771
+ const descriptionToRender = shouldRenderSummary
4772
+ ? streamedSummaryText ||
4773
+ (isSummaryLoading ? "Generating summary..." : eventDescriptionText)
4774
+ : eventDescriptionText;
4775
+ const summarizeButtonLabel = isSummaryLoading
4776
+ ? "Summarizing..."
4777
+ : shouldRenderSummary
4778
+ ? "Summarize again"
4779
+ : "Summarize";
4264
4780
 
4265
4781
  const formatDate = (date: Date) => {
4266
4782
  return new Intl.DateTimeFormat(this.locale, {
@@ -4399,6 +4915,7 @@ export class CalendarViewElement extends LitElement {
4399
4915
  this.selectedEventForDetail = null;
4400
4916
  this.selectedEventRect = null;
4401
4917
  this.isDescriptionExpanded = false;
4918
+ this.clearDescriptionSummary();
4402
4919
  this.requestUpdate();
4403
4920
  }}
4404
4921
  >×</button>
@@ -4406,7 +4923,9 @@ export class CalendarViewElement extends LitElement {
4406
4923
 
4407
4924
  <div class="event-detail-body">
4408
4925
  ${(() => {
4409
- const teamsMatch = event.description?.match(/https:\/\/teams\.microsoft\.com\/[^\s<>"]+/);
4926
+ const teamsMatch = eventDescription?.match(
4927
+ /https:\/\/teams\.microsoft\.com\/[^\s<>"]+/,
4928
+ );
4410
4929
  const teamsUrl = teamsMatch ? teamsMatch[0] : null;
4411
4930
  if (!event.location && !teamsUrl) return null;
4412
4931
  return html`
@@ -4414,7 +4933,11 @@ export class CalendarViewElement extends LitElement {
4414
4933
  <div class="event-detail-label">Location</div>
4415
4934
  <div class="event-detail-value">
4416
4935
  ${event.location ? html`<div>${event.location}</div>` : null}
4417
- ${teamsUrl ? html`<a href="${teamsUrl}" class="event-detail-link" target="_blank" rel="noopener">Join Microsoft Teams Meeting</a>` : null}
4936
+ ${
4937
+ teamsUrl
4938
+ ? html`<a href="${teamsUrl}" class="event-detail-link" target="_blank" rel="noopener">Join Microsoft Teams Meeting</a>`
4939
+ : null
4940
+ }
4418
4941
  </div>
4419
4942
  </div>
4420
4943
  `;
@@ -4509,24 +5032,64 @@ export class CalendarViewElement extends LitElement {
4509
5032
  }
4510
5033
 
4511
5034
  ${
4512
- event.readOnly ||
4513
- (event.organizer != null &&
4514
- !this.currentUserEmails.has(event.organizer.email))
4515
- ? event.description
4516
- ? html`
5035
+ useDescriptionSummary
5036
+ ? html`
5037
+ <div class="event-detail-section">
5038
+ <div class="event-detail-label">Description</div>
5039
+ <div class="event-detail-description ${
5040
+ shouldRenderSummary || this.isDescriptionExpanded
5041
+ ? "expanded"
5042
+ : ""
5043
+ }">
5044
+ <pre class="event-detail-value">${descriptionToRender}</pre>
5045
+ </div>
5046
+ ${
5047
+ shouldRenderSummary && this.descriptionSummaryError
5048
+ ? html`<div class="event-detail-value" style="opacity: 0.7;">Summary unavailable: ${this.descriptionSummaryError}. Showing original description.</div>`
5049
+ : null
5050
+ }
5051
+ <div class="description-actions">
5052
+ ${
5053
+ !shouldRenderSummary
5054
+ ? html`<button
5055
+ class="description-see-more"
5056
+ @click=${() => {
5057
+ this.isDescriptionExpanded =
5058
+ !this.isDescriptionExpanded;
5059
+ this.requestUpdate();
5060
+ }}
5061
+ >
5062
+ ${
5063
+ this.isDescriptionExpanded ? "Show less" : "Show more"
5064
+ }
5065
+ </button>`
5066
+ : null
5067
+ }
5068
+ <button
5069
+ class="description-see-more"
5070
+ ?disabled=${isSummaryLoading}
5071
+ @click=${() => this.requestDescriptionSummary(event)}
5072
+ >
5073
+ ${summarizeButtonLabel}
5074
+ </button>
5075
+ </div>
5076
+ </div>
5077
+ `
5078
+ : event.readOnly ||
5079
+ (event.organizer != null &&
5080
+ !this.currentUserEmails.has(event.organizer.email))
5081
+ ? eventDescription
5082
+ ? html`
4517
5083
  <div class="event-detail-section">
4518
5084
  <div class="event-detail-label">Description</div>
4519
5085
  <div class="event-detail-description ${
4520
5086
  this.isDescriptionExpanded ? "expanded" : ""
4521
5087
  }">
4522
- <pre class="event-detail-value">${event.description.replaceAll(
4523
- "\\n",
4524
- "\n",
4525
- )}</pre>
5088
+ <pre class="event-detail-value">${descriptionToRender}</pre>
4526
5089
  </div>
4527
5090
  ${
4528
- event.description.length > 300 ||
4529
- event.description.split("\n").length > 8
5091
+ eventDescription.length > 300 ||
5092
+ eventDescription.split("\n").length > 8
4530
5093
  ? html`
4531
5094
  <button
4532
5095
  class="description-see-more"
@@ -4543,13 +5106,13 @@ export class CalendarViewElement extends LitElement {
4543
5106
  }
4544
5107
  </div>
4545
5108
  `
4546
- : null
4547
- : html`
5109
+ : null
5110
+ : html`
4548
5111
  <div class="event-detail-section">
4549
5112
  <div class="event-detail-label">Description</div>
4550
5113
  <textarea
4551
5114
  class="event-detail-description-input"
4552
- .value=${event.description ?? ""}
5115
+ .value=${eventDescription}
4553
5116
  placeholder="Add description..."
4554
5117
  rows="3"
4555
5118
  @input=${(e: Event) => {
@@ -4579,7 +5142,7 @@ export class CalendarViewElement extends LitElement {
4579
5142
  clearTimeout(this.updateEventTimeout);
4580
5143
  this.updateEventTimeout = null;
4581
5144
  }
4582
- if (newDescription !== (event.description ?? "")) {
5145
+ if (newDescription !== eventDescription) {
4583
5146
  this.dispatchEvent(
4584
5147
  new CustomEvent("update-event", {
4585
5148
  detail: {
@@ -4668,7 +5231,9 @@ export class CalendarViewElement extends LitElement {
4668
5231
  @mousedown=${this.onCanvasMouseDown}
4669
5232
  @mousemove=${this.onScrollContainerMouseMove}
4670
5233
  >
4671
- <div class="scroll-content" style="height: ${this.totalHeight}px;">
5234
+ <div class="scroll-content" style="height: ${
5235
+ this.totalHeight
5236
+ }px; width: ${this.getContentWidth()}px;">
4672
5237
  </div>
4673
5238
  ${this.renderSelection()}
4674
5239
  </div>
@@ -4682,27 +5247,42 @@ export class CalendarViewElement extends LitElement {
4682
5247
  <div class="toolbar">
4683
5248
  <div class="toolbar-left">
4684
5249
  <button class="toolbar-button" title="Month" @click=${
4685
- this.scrollToMonth
4686
- }>
5250
+ this.scrollToMonth
5251
+ }>
4687
5252
  Month
4688
5253
  </button>
4689
5254
  <button class="toolbar-button" title="Today" @click=${
4690
- this.scrollToToday
4691
- }>
5255
+ this.scrollToToday
5256
+ }>
4692
5257
  Today
4693
5258
  </button>
4694
5259
  <button ?disabled=${
4695
- this.historyStack.length === 0 ||
4696
- this.historyIndex >= this.historyStack.length - 1
4697
- } class="toolbar-button" title="Back" @click=${this.goBack}>
5260
+ this.historyStack.length === 0 ||
5261
+ this.historyIndex >= this.historyStack.length - 1
5262
+ } class="toolbar-button" title="Back" @click=${this.goBack}>
4698
5263
 
4699
5264
  </button>
4700
5265
  <button ?disabled=${
4701
- this.historyStack.length === 0 || this.historyIndex === 0
4702
- } class="toolbar-button" title="Forward" @click=${this.goForward}>
5266
+ this.historyStack.length === 0 || this.historyIndex === 0
5267
+ } class="toolbar-button" title="Forward" @click=${
5268
+ this.goForward
5269
+ }>
4703
5270
 
4704
5271
  </button>
4705
5272
  <div class="toolbar-zoom">
5273
+ <span class="toolbar-slider-label">Width</span>
5274
+ <input
5275
+ type="range"
5276
+ class="toolbar-zoom-slider"
5277
+ min="${MIN_DAY_COLUMN_WIDTH}"
5278
+ max="${MAX_DAY_COLUMN_WIDTH}"
5279
+ .value=${this.minDayColumnWidth}
5280
+ @input=${this.onDayColumnWidthSliderChange}
5281
+ title="Adjust minimum day column width"
5282
+ />
5283
+ </div>
5284
+ <div class="toolbar-zoom">
5285
+ <span class="toolbar-slider-label">Zoom</span>
4706
5286
  <input
4707
5287
  type="range"
4708
5288
  class="toolbar-zoom-slider"
@@ -4723,9 +5303,9 @@ export class CalendarViewElement extends LitElement {
4723
5303
  title="Select theme"
4724
5304
  >
4725
5305
  ${availableThemes.map(
4726
- (theme) =>
4727
- html`<option value="${theme.name}">${theme.label}</option>`,
4728
- )}
5306
+ (theme) =>
5307
+ html`<option value="${theme.name}">${theme.label}</option>`,
5308
+ )}
4729
5309
  </select>-->
4730
5310
  <div class="toolbar-search">
4731
5311
  <span class="toolbar-search-icon">🔍</span>