@luckydye/calendar 1.3.0 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,6 @@ import {
3
3
  type Attendee,
4
4
  type CalendarEvent,
5
5
  CalendarInternal,
6
- type EventSegment,
7
6
  type WeekInfo,
8
7
  NOTIFICATION_PRESETS,
9
8
  } from "./CalendarInternal.js";
@@ -19,12 +18,16 @@ import { serializeEventsToICal } from "./ICal.js";
19
18
  import { queueStatus } from "./StatusMessage.js";
20
19
  import type { StatusBarData } from "./StatusBar.js";
21
20
  import { IndexedDBStorage } from "./IndexedDBStorage.js";
21
+ import { sanitizeEventDescription } from "./DescriptionSanitizer.js";
22
+ import { TIME_SCALE_DAY_HEIGHT, type CalendarLayer, type LayerContext } from "./CalendarLayer.js";
23
+ import { createGridLayer } from "./layers/GridLayer.js";
24
+ import { createEventsLayer, type EventRect, type EventsState } from "./layers/EventsLayer.js";
25
+ import { createTimeseriesHeatmapLayer } from "./layers/TimeseriesHeatmapLayer.js";
22
26
 
23
- const MIN_DAY_HEIGHT = 100;
27
+ const MIN_DAY_HEIGHT = 50;
24
28
  const MAX_DAY_HEIGHT = 3000; // 1px per minute
25
29
  const LEFT_GUTTER_WIDTH = 60;
26
30
  const MINIMAP_WIDTH = 12;
27
- const MIN_EVENT_HEIGHT = 20;
28
31
 
29
32
  export class CalendarViewElement extends LitElement {
30
33
  static styles = css`
@@ -257,7 +260,7 @@ export class CalendarViewElement extends LitElement {
257
260
  left: 60px;
258
261
  right: 12px;
259
262
  pointer-events: none;
260
- z-index: 2;
263
+ z-index: 101;
261
264
  overflow: hidden;
262
265
  }
263
266
 
@@ -298,7 +301,7 @@ export class CalendarViewElement extends LitElement {
298
301
  flex-direction: row;
299
302
  backdrop-filter: blur(10px);
300
303
  box-shadow: var(--shadow-overlay, 0 4px 12px rgba(0, 0, 0, 0.9));
301
- z-index: 100;
304
+ z-index: 102;
302
305
  }
303
306
 
304
307
  .event-detail-header {
@@ -677,6 +680,19 @@ export class CalendarViewElement extends LitElement {
677
680
  text-align: left;
678
681
  }
679
682
 
683
+ .description-actions {
684
+ display: flex;
685
+ gap: 12px;
686
+ align-items: center;
687
+ flex-wrap: wrap;
688
+ }
689
+
690
+ .description-actions .description-see-more {
691
+ display: inline-block;
692
+ margin-top: 8px;
693
+ padding: 0;
694
+ }
695
+
680
696
  .description-see-more:hover {
681
697
  color: var(--accent-hover, rgb(120, 170, 255));
682
698
  }
@@ -854,6 +870,14 @@ export class CalendarViewElement extends LitElement {
854
870
  minimapBufferCanvas: HTMLCanvasElement | null = null;
855
871
  minimapBufferCtx: CanvasRenderingContext2D | null = null;
856
872
  stripePatternCanvas: HTMLCanvasElement | null = null;
873
+ layers: CalendarLayer[] = [];
874
+ eventsLayer: ReturnType<typeof createEventsLayer> | null = null;
875
+ heatmapEvents: CalendarEvent[] = [];
876
+ heatmapRange: { start: Date; end: Date } | null = null;
877
+ heatmapQueryKey: string | null = null;
878
+ heatmapQueryToken = 0;
879
+ timeseriesSourceIds: Set<string> = new Set();
880
+ timeseriesSourceCacheKey: string | null = null;
857
881
  scrollContainer: HTMLElement | null = null;
858
882
  scrollContent: HTMLElement | null = null;
859
883
  resizeObserver: ResizeObserver | null = null;
@@ -875,9 +899,12 @@ export class CalendarViewElement extends LitElement {
875
899
  cursorPosition: { x: number; y: number } | null = null; // Mouse position in calendar coordinates
876
900
  animationFrame: number | null = null;
877
901
  isDraggingMinimap = false;
902
+ altKeyActive = false; // Tracks if alt/meta key is held (bypasses event interaction)
878
903
  isFiltered = false; // Tracks if filter is currently active
879
904
  timeUpdateInterval: ReturnType<typeof setInterval> | null = null;
880
905
  isExtendingRange = false; // Prevents concurrent range extensions
906
+ boundaryCheckInterval: ReturnType<typeof setInterval> | null = null;
907
+ lastWheelTime = 0; // Timestamp of last wheel event, used to distinguish wheel vs scrollbar drag
881
908
  isCreatingEvent = false;
882
909
  eventCreationStart: { x: number; y: number } | null = null;
883
910
  eventCreationEnd: { x: number; y: number } | null = null;
@@ -979,6 +1006,11 @@ export class CalendarViewElement extends LitElement {
979
1006
  height: number;
980
1007
  } | null = null;
981
1008
  isDescriptionExpanded = false;
1009
+ descriptionSummaryTargetKey: string | null = null;
1010
+ descriptionSummaryText = "";
1011
+ descriptionSummaryLoading = false;
1012
+ descriptionSummaryError: string | null = null;
1013
+ private requestedDescriptionSummaryKey: string | null = null;
982
1014
 
983
1015
  notificationPopoverOpen = false;
984
1016
  scheduledNotifications: any[] = [];
@@ -992,6 +1024,7 @@ export class CalendarViewElement extends LitElement {
992
1024
  currentTheme: ThemeName = loadThemePreference();
993
1025
 
994
1026
  activeCalendarColor: string | null = null;
1027
+ activeCalendarId: string | null = null;
995
1028
 
996
1029
  get filter() {
997
1030
  return this.internal.getFilter();
@@ -1302,6 +1335,8 @@ export class CalendarViewElement extends LitElement {
1302
1335
  window.addEventListener("mousemove", this.onMouseMove);
1303
1336
  window.addEventListener("mouseup", this.onMouseUp);
1304
1337
  window.addEventListener("paste", this.onPaste);
1338
+ window.addEventListener("keydown", this.onKeyDown);
1339
+ window.addEventListener("keyup", this.onKeyUp);
1305
1340
  }
1306
1341
 
1307
1342
  disconnectedCallback() {
@@ -1310,6 +1345,8 @@ export class CalendarViewElement extends LitElement {
1310
1345
  window.removeEventListener("mousemove", this.onMouseMove);
1311
1346
  window.removeEventListener("mouseup", this.onMouseUp);
1312
1347
  window.removeEventListener("paste", this.onPaste);
1348
+ window.removeEventListener("keydown", this.onKeyDown);
1349
+ window.removeEventListener("keyup", this.onKeyUp);
1313
1350
 
1314
1351
  if (this.scrollContainer) {
1315
1352
  this.scrollContainer.removeEventListener(
@@ -1326,6 +1363,9 @@ export class CalendarViewElement extends LitElement {
1326
1363
  if (this.timeUpdateInterval) {
1327
1364
  clearInterval(this.timeUpdateInterval);
1328
1365
  }
1366
+ if (this.boundaryCheckInterval) {
1367
+ clearInterval(this.boundaryCheckInterval);
1368
+ }
1329
1369
  }
1330
1370
 
1331
1371
  onScrollContainerMouseLeave = (): void => {
@@ -1338,6 +1378,97 @@ export class CalendarViewElement extends LitElement {
1338
1378
  this.repositionEventDetailOverlay();
1339
1379
  }
1340
1380
 
1381
+ clearDescriptionSummary(): void {
1382
+ if (this.requestedDescriptionSummaryKey) {
1383
+ this.dispatchEvent(
1384
+ new CustomEvent("cancel-description-summary", {
1385
+ detail: { key: this.requestedDescriptionSummaryKey },
1386
+ bubbles: true,
1387
+ composed: true,
1388
+ }),
1389
+ );
1390
+ }
1391
+ this.requestedDescriptionSummaryKey = null;
1392
+ this.descriptionSummaryTargetKey = null;
1393
+ this.descriptionSummaryText = "";
1394
+ this.descriptionSummaryLoading = false;
1395
+ this.descriptionSummaryError = null;
1396
+ this.requestUpdate();
1397
+ }
1398
+
1399
+ private getDescriptionSummaryTargetKey(event: CalendarEvent): string {
1400
+ return `${event.id}:${sanitizeEventDescription(event.description ?? "")}`;
1401
+ }
1402
+
1403
+ private requestDescriptionSummary(event: CalendarEvent): void {
1404
+ const description = sanitizeEventDescription(event.description ?? "").trim();
1405
+ if (description.length <= 200) return;
1406
+
1407
+ const targetKey = this.getDescriptionSummaryTargetKey(event);
1408
+ this.clearDescriptionSummary();
1409
+ this.requestedDescriptionSummaryKey = targetKey;
1410
+ this.descriptionSummaryTargetKey = targetKey;
1411
+ this.descriptionSummaryText = "";
1412
+ this.descriptionSummaryLoading = true;
1413
+ this.descriptionSummaryError = null;
1414
+ this.dispatchEvent(
1415
+ new CustomEvent("request-description-summary", {
1416
+ detail: {
1417
+ event,
1418
+ key: targetKey,
1419
+ description,
1420
+ },
1421
+ bubbles: true,
1422
+ composed: true,
1423
+ }),
1424
+ );
1425
+ this.requestUpdate();
1426
+ }
1427
+
1428
+ startDescriptionSummary(key: string): void {
1429
+ if (key !== this.descriptionSummaryTargetKey) return;
1430
+ this.descriptionSummaryText = "";
1431
+ this.descriptionSummaryLoading = true;
1432
+ this.descriptionSummaryError = null;
1433
+ this.requestUpdate();
1434
+ }
1435
+
1436
+ appendDescriptionSummaryChunk(key: string, chunk: string): void {
1437
+ if (key !== this.descriptionSummaryTargetKey) return;
1438
+ if (!chunk) return;
1439
+ this.descriptionSummaryText += chunk;
1440
+ this.descriptionSummaryLoading = true;
1441
+ this.requestUpdate();
1442
+ }
1443
+
1444
+ finishDescriptionSummary(key: string, error: string | null = null): void {
1445
+ if (key !== this.descriptionSummaryTargetKey) return;
1446
+ this.descriptionSummaryLoading = false;
1447
+ this.descriptionSummaryError = error;
1448
+ this.requestUpdate();
1449
+ }
1450
+
1451
+ failDescriptionSummary(key: string, message: string): void {
1452
+ if (key !== this.descriptionSummaryTargetKey) return;
1453
+ this.descriptionSummaryLoading = false;
1454
+ this.descriptionSummaryError = message;
1455
+ if (!this.descriptionSummaryText && this.selectedEventForDetail?.description) {
1456
+ this.descriptionSummaryText = this.selectedEventForDetail.description.replaceAll(
1457
+ "\\n",
1458
+ "\n",
1459
+ );
1460
+ }
1461
+ this.requestUpdate();
1462
+ }
1463
+
1464
+ cancelDescriptionSummary(key: string): void {
1465
+ if (key !== this.descriptionSummaryTargetKey) {
1466
+ return;
1467
+ }
1468
+ this.descriptionSummaryLoading = false;
1469
+ this.requestUpdate();
1470
+ }
1471
+
1341
1472
  repositionEventDetailOverlay(): void {
1342
1473
  if (!this.selectedEventForDetail || !this.selectedEventRect) return;
1343
1474
 
@@ -1367,6 +1498,24 @@ export class CalendarViewElement extends LitElement {
1367
1498
  this.minimapBufferCanvas = document.createElement("canvas");
1368
1499
  this.minimapBufferCtx = this.minimapBufferCanvas.getContext("2d");
1369
1500
 
1501
+ const self = this;
1502
+ const eventsState: EventsState = {
1503
+ get events() { return self.events; },
1504
+ get hoveredEventId() { return self.hoveredEventId; },
1505
+ isEventSelected: (event) => self.internal.isEventSelected(event),
1506
+ shouldRenderEventWithStripes: (event) => self.shouldRenderEventWithStripes(event),
1507
+ getStripePatternCanvas: () => self.getStripePatternCanvas(),
1508
+ };
1509
+ const heatmapState = {
1510
+ get events() { return self.heatmapEvents; },
1511
+ };
1512
+ this.eventsLayer = createEventsLayer(eventsState);
1513
+ this.layers = [
1514
+ createGridLayer(),
1515
+ createTimeseriesHeatmapLayer(heatmapState),
1516
+ this.eventsLayer,
1517
+ ];
1518
+
1370
1519
  this.weeks = this.internal.generateWeeks();
1371
1520
 
1372
1521
  // Restore zoom level from localStorage
@@ -1399,13 +1548,48 @@ export class CalendarViewElement extends LitElement {
1399
1548
  this.requestUpdate();
1400
1549
  }, 10000);
1401
1550
 
1551
+ // Extend range while stationary at the buffer boundary (no scroll events fire when idle)
1552
+ this.boundaryCheckInterval = setInterval(() => {
1553
+ this.checkAndExtendRange();
1554
+ }, 500);
1555
+
1402
1556
  // Try to restore saved scroll position, otherwise scroll to today
1403
1557
  this.loadScrollPosition();
1404
1558
 
1405
1559
  let previousFilter = this.filter;
1406
1560
 
1407
1561
  for await (const events of this.internal.events()) {
1408
- this.events = events;
1562
+ const timeseriesIds = this.loadTimeseriesSourceIds();
1563
+ let normalized = events;
1564
+ if (timeseriesIds.size > 0) {
1565
+ let needsClone = false;
1566
+ for (const event of events) {
1567
+ if (
1568
+ event.visualStyle !== "heatmap" &&
1569
+ event.sourceId &&
1570
+ timeseriesIds.has(event.sourceId)
1571
+ ) {
1572
+ needsClone = true;
1573
+ break;
1574
+ }
1575
+ }
1576
+ if (needsClone) {
1577
+ normalized = events.map((event) => {
1578
+ if (
1579
+ event.visualStyle === "heatmap" ||
1580
+ !event.sourceId ||
1581
+ !timeseriesIds.has(event.sourceId)
1582
+ ) {
1583
+ return event;
1584
+ }
1585
+ return { ...event, visualStyle: "heatmap" };
1586
+ });
1587
+ }
1588
+ }
1589
+ this.events = normalized;
1590
+ if (this.heatmapRange) {
1591
+ void this.refreshHeatmapEvents(true);
1592
+ }
1409
1593
 
1410
1594
  this.renderCanvas();
1411
1595
 
@@ -1548,6 +1732,101 @@ export class CalendarViewElement extends LitElement {
1548
1732
  }
1549
1733
  }
1550
1734
 
1735
+ resolveStyles(): Record<string, string> {
1736
+ const cs = getComputedStyle(this);
1737
+ const props = [
1738
+ "--bg-today",
1739
+ "--accent-current-time",
1740
+ "--grid-color",
1741
+ "--grid-color-strong",
1742
+ "--grid-color-hover",
1743
+ "--text-muted",
1744
+ "--text-primary",
1745
+ "--text-inverse",
1746
+ "--bg-primary",
1747
+ "--bg-elevated",
1748
+ ];
1749
+ const styles: Record<string, string> = {};
1750
+ for (const prop of props) {
1751
+ styles[prop] = cs.getPropertyValue(prop).trim();
1752
+ }
1753
+ return styles;
1754
+ }
1755
+
1756
+ private loadTimeseriesSourceIds(): Set<string> {
1757
+ const saved = localStorage.getItem("caldav-sources") || "";
1758
+ if (saved && saved === this.timeseriesSourceCacheKey) {
1759
+ return this.timeseriesSourceIds;
1760
+ }
1761
+ const ids = new Set<string>();
1762
+ if (saved) {
1763
+ try {
1764
+ const sources = JSON.parse(saved) as Array<{ id?: string; type?: string }>;
1765
+ for (const source of sources) {
1766
+ if (source?.type === "timeseries-json" && source.id) {
1767
+ ids.add(source.id);
1768
+ }
1769
+ }
1770
+ } catch {
1771
+ // Ignore malformed storage
1772
+ }
1773
+ }
1774
+ this.timeseriesSourceCacheKey = saved;
1775
+ this.timeseriesSourceIds = ids;
1776
+ return ids;
1777
+ }
1778
+
1779
+ private updateHeatmapRange(visibleWeeks: WeekInfo[]): void {
1780
+ if (visibleWeeks.length === 0) {
1781
+ this.heatmapRange = null;
1782
+ return;
1783
+ }
1784
+ const firstWeek = visibleWeeks[0]!;
1785
+ const lastWeek = visibleWeeks[visibleWeeks.length - 1]!;
1786
+ const startDay = firstWeek.days[0];
1787
+ const endDay = lastWeek.days[6];
1788
+ if (!startDay || !endDay) {
1789
+ this.heatmapRange = null;
1790
+ return;
1791
+ }
1792
+ const start = new Date(startDay);
1793
+ start.setHours(0, 0, 0, 0);
1794
+ const end = new Date(endDay);
1795
+ end.setHours(23, 59, 59, 999);
1796
+ this.heatmapRange = { start, end };
1797
+ }
1798
+
1799
+ private async refreshHeatmapEvents(force = false): Promise<void> {
1800
+ if (!this.heatmapRange) return;
1801
+ const { start, end } = this.heatmapRange;
1802
+ const timeseriesIds = this.loadTimeseriesSourceIds();
1803
+ const enabledKey = [...this.internal.enabledCalendars].join(",");
1804
+ const lockedKey = [...this.internal.lockedCalendars].join(",");
1805
+ const key = `${start.toISOString()}::${end.toISOString()}::${this.filter || ""}::${enabledKey}::${lockedKey}`;
1806
+ if (!force && key === this.heatmapQueryKey) return;
1807
+ this.heatmapQueryKey = key;
1808
+
1809
+ const token = ++this.heatmapQueryToken;
1810
+ try {
1811
+ let events: CalendarEvent[];
1812
+ if (this.internal.storage) {
1813
+ events = await this.internal.storage.queryEvents(start, end);
1814
+ } else {
1815
+ events = this.events;
1816
+ }
1817
+ if (token !== this.heatmapQueryToken) return;
1818
+
1819
+ const filtered = this.internal.filterEvents(events, this.filter);
1820
+ this.heatmapEvents = filtered.filter((event) => {
1821
+ if (event.visualStyle === "heatmap") return true;
1822
+ return event.sourceId ? timeseriesIds.has(event.sourceId) : false;
1823
+ });
1824
+ this.renderCanvas();
1825
+ } catch (error) {
1826
+ console.warn("Failed to refresh heatmap events:", error);
1827
+ }
1828
+ }
1829
+
1551
1830
  renderCanvas(): void {
1552
1831
  if (!this.ctx || !this.canvas || !this.scrollContainer) return;
1553
1832
 
@@ -1561,292 +1840,48 @@ export class CalendarViewElement extends LitElement {
1561
1840
  const scrollTop = this.scrollTop;
1562
1841
  const gridWidth = width - LEFT_GUTTER_WIDTH;
1563
1842
  const dayWidth = gridWidth / this._columnsPerRow;
1564
- const today = new Date();
1565
1843
 
1566
- // Find visible weeks
1567
1844
  const visibleWeeks = this.weeks.filter(
1568
1845
  (w) =>
1569
1846
  w.height > 0 &&
1570
1847
  w.yOffset + w.height > scrollTop &&
1571
1848
  w.yOffset < scrollTop + height,
1572
1849
  );
1850
+ this.updateHeatmapRange(visibleWeeks);
1851
+ this.refreshHeatmapEvents();
1852
+
1853
+ const lc: LayerContext = {
1854
+ ctx,
1855
+ width,
1856
+ height,
1857
+ scrollTop,
1858
+ dayWidth,
1859
+ dayHeight: this.dayHeight,
1860
+ leftGutterWidth: LEFT_GUTTER_WIDTH,
1861
+ columnsPerRow: this._columnsPerRow,
1862
+ rowsPerWeek: this.rowsPerWeek,
1863
+ visibleWeeks,
1864
+ allWeeks: this.weeks,
1865
+ fontFamily,
1866
+ styles: this.resolveStyles(),
1867
+ getDayVisualPosition: (dayIndex) => this.getDayVisualPosition(dayIndex),
1868
+ filter: this.filter,
1869
+ };
1573
1870
 
1574
- // Draw today highlight and current time indicator
1575
- const showTimeScale = this.dayHeight >= 300;
1576
- for (const week of visibleWeeks) {
1577
- const todayIndex = week.days.findIndex((d) =>
1578
- CalendarInternal.isSameDay(d, today),
1579
- );
1580
- if (todayIndex >= 0) {
1581
- const { row, col } = this.getDayVisualPosition(todayIndex);
1582
- const x = LEFT_GUTTER_WIDTH + col * dayWidth;
1583
- const dayY = week.yOffset + row * this.dayHeight - scrollTop;
1584
- const bgToday =
1585
- getComputedStyle(this).getPropertyValue("--bg-today").trim() ||
1586
- "rgba(255, 255, 255, 0.05)";
1587
- ctx.fillStyle = bgToday;
1588
- ctx.fillRect(x, dayY, dayWidth, this.dayHeight);
1589
-
1590
- if (showTimeScale) {
1591
- // Draw current time indicator line (zoomed in)
1592
- const now = new Date();
1593
- const currentMinutes = now.getHours() * 60 + now.getMinutes();
1594
- const timeY = dayY + (currentMinutes / 1440) * this.dayHeight;
1595
- if (timeY >= 0 && timeY <= height) {
1596
- const accentTime =
1597
- getComputedStyle(this)
1598
- .getPropertyValue("--accent-current-time")
1599
- .trim() || "rgba(255, 0, 0, 0.8)";
1600
- ctx.strokeStyle = accentTime;
1601
- ctx.lineWidth = 1;
1602
- ctx.beginPath();
1603
- ctx.moveTo(x, timeY);
1604
- ctx.lineTo(x + dayWidth, timeY);
1605
- ctx.stroke();
1606
- ctx.lineWidth = 1;
1607
- }
1608
- }
1609
- }
1610
- }
1611
-
1612
- // Draw grid lines
1613
- ctx.strokeStyle =
1614
- getComputedStyle(this).getPropertyValue("--grid-color").trim() ||
1615
- "rgba(255, 255, 255, 0.1)";
1616
- ctx.lineWidth = 1;
1617
-
1618
- // Vertical lines (day separators) - draw for each column
1619
- for (let i = 1; i <= this._columnsPerRow; i++) {
1620
- const x = LEFT_GUTTER_WIDTH + i * dayWidth;
1621
- ctx.beginPath();
1622
- ctx.moveTo(x, 0);
1623
- ctx.lineTo(x, height);
1624
- ctx.stroke();
1871
+ for (const layer of this.layers) {
1872
+ if (!layer.enabled) continue;
1873
+ ctx.save();
1874
+ layer.render(lc);
1875
+ ctx.restore();
1625
1876
  }
1626
1877
 
1627
- // Horizontal lines (week separators) and left gutter content
1628
- for (let i = 0; i < visibleWeeks.length; i++) {
1629
- const week = visibleWeeks[i];
1630
- if (!week) continue;
1631
- const y = week.yOffset - scrollTop;
1632
-
1633
- // Draw gap indicator if there are hidden weeks before this one
1634
- if (this.filter && i > 0) {
1635
- const prevWeek = visibleWeeks[i - 1];
1636
- if (!prevWeek) continue;
1637
- const prevWeekIndex = this.weeks.indexOf(prevWeek);
1638
- const currentWeekIndex = this.weeks.indexOf(week);
1639
- const hiddenWeeks = currentWeekIndex - prevWeekIndex - 1;
1640
-
1641
- if (hiddenWeeks > 0) {
1642
- // Draw gap indicator at the top of the current week
1643
- const gapY = y;
1644
-
1645
- // Draw dashed line
1646
- const gridColorStrong =
1647
- getComputedStyle(this)
1648
- .getPropertyValue("--grid-color-strong")
1649
- .trim() || "rgba(255, 255, 255, 0.3)";
1650
- ctx.strokeStyle = gridColorStrong;
1651
- ctx.lineWidth = 1;
1652
- ctx.setLineDash([4, 4]);
1653
- ctx.beginPath();
1654
- ctx.moveTo(LEFT_GUTTER_WIDTH, gapY);
1655
- ctx.lineTo(width, gapY);
1656
- ctx.stroke();
1657
- ctx.setLineDash([]);
1658
-
1659
- // Draw ellipsis in the center
1660
- const textMuted =
1661
- getComputedStyle(this).getPropertyValue("--text-muted").trim() ||
1662
- "rgba(255, 255, 255, 0.4)";
1663
- ctx.fillStyle = textMuted;
1664
- ctx.textAlign = "center";
1665
- const ellipsisText = `⋯ ${hiddenWeeks} week${
1666
- hiddenWeeks > 1 ? "s" : ""
1667
- }`;
1668
-
1669
- // Draw background pill for the ellipsis
1670
- const textWidth = ctx.measureText(ellipsisText).width;
1671
- const pillPadding = 8;
1672
- const pillX =
1673
- (LEFT_GUTTER_WIDTH + width) / 2 - textWidth / 2 - pillPadding;
1674
- const pillY = gapY - 8;
1675
- const pillWidth = textWidth + pillPadding * 2;
1676
- const pillHeight = 16;
1677
-
1678
- const bgPrimary =
1679
- getComputedStyle(this).getPropertyValue("--bg-primary").trim() ||
1680
- "rgba(30, 30, 30, 0.9)";
1681
- ctx.fillStyle = bgPrimary;
1682
- ctx.beginPath();
1683
- ctx.roundRect(pillX, pillY, pillWidth, pillHeight, 8);
1684
- ctx.fill();
1685
-
1686
- ctx.fillStyle = textMuted;
1687
- ctx.fillText(ellipsisText, (LEFT_GUTTER_WIDTH + width) / 2, gapY + 4);
1688
- }
1689
- }
1690
-
1691
- // Week separator line
1692
- ctx.strokeStyle =
1693
- getComputedStyle(this).getPropertyValue("--grid-color").trim() ||
1694
- "rgba(255, 255, 255, 0.1)";
1695
- ctx.beginPath();
1696
- ctx.moveTo(LEFT_GUTTER_WIDTH, y);
1697
- ctx.lineTo(width, y);
1698
- ctx.stroke();
1699
-
1700
- // Draw horizontal lines between visual rows within this week
1701
- for (let row = 1; row < this.rowsPerWeek; row++) {
1702
- const rowY = y + row * this.dayHeight;
1703
- if (rowY >= 0 && rowY <= height) {
1704
- ctx.beginPath();
1705
- ctx.moveTo(LEFT_GUTTER_WIDTH, rowY);
1706
- ctx.lineTo(width, rowY);
1707
- ctx.stroke();
1708
- }
1709
- }
1710
-
1711
- // Left gutter: week number and time scale
1712
- const hourLabelOpacity = Math.max(
1713
- 0,
1714
- Math.min(1, (this.dayHeight - 300) / 300),
1715
- );
1716
-
1717
- ctx.font = `500 11px ${fontFamily}`;
1718
- ctx.textBaseline = "bottom";
1719
- ctx.textAlign = "right";
1720
-
1721
- // Draw hourly lines and labels for each visual row
1722
- const gridColor =
1723
- getComputedStyle(this).getPropertyValue("--grid-color").trim() ||
1724
- "rgba(255, 255, 255, 0.1)";
1725
- const textMuted =
1726
- getComputedStyle(this).getPropertyValue("--text-muted").trim() ||
1727
- "rgba(255, 255, 255, 0.4)";
1728
- ctx.strokeStyle = gridColor.replace(
1729
- /[\d.]+\)$/,
1730
- `${0.05 * hourLabelOpacity})`,
1731
- );
1732
-
1733
- // Calculate opacity for hour labels based on zoom level
1734
- // Fade in as we zoom in from 200px to 400px
1735
- ctx.fillStyle = textMuted.replace(
1736
- /[\d.]+\)$/,
1737
- `${0.4 * hourLabelOpacity})`,
1738
- );
1739
-
1740
- for (let row = 0; row < this.rowsPerWeek; row++) {
1741
- const rowY = y + row * this.dayHeight;
1742
- for (let hour = 0; hour < 24; hour++) {
1743
- const hourY = rowY + (hour / 24) * this.dayHeight;
1744
- if (hourY >= 0 && hourY <= height) {
1745
- // Hour line
1746
- ctx.beginPath();
1747
- ctx.moveTo(LEFT_GUTTER_WIDTH, hourY);
1748
- ctx.lineTo(width, hourY);
1749
- ctx.stroke();
1750
-
1751
- // Hour label (only draw if opacity is significant)
1752
- if (hourLabelOpacity > 0.1) {
1753
- const label = `${hour.toString().padStart(2, "0")}:00`;
1754
- ctx.fillText(label, 48, hourY + 4);
1755
- }
1756
- }
1757
- }
1758
- }
1759
-
1760
- // Draw current time indicator in left gutter (only when timescale is visible and today is in this week)
1761
- if (hourLabelOpacity > 0.1) {
1762
- const today = new Date();
1763
- const todayIndex = week.days.findIndex((d) =>
1764
- CalendarInternal.isSameDay(d, today),
1765
- );
1766
-
1767
- if (todayIndex >= 0) {
1768
- const { row } = this.getDayVisualPosition(todayIndex);
1769
- const currentMinutes = today.getHours() * 60 + today.getMinutes();
1770
- const timeY =
1771
- y + row * this.dayHeight + (currentMinutes / 1440) * this.dayHeight;
1772
-
1773
- if (timeY >= 0 && timeY <= height) {
1774
- const hours = today.getHours().toString().padStart(2, "0");
1775
- const minutes = today.getMinutes().toString().padStart(2, "0");
1776
- const timeText = `${hours}:${minutes}`;
1777
-
1778
- ctx.save();
1779
- ctx.textAlign = "right";
1780
- ctx.textBaseline = "middle";
1781
-
1782
- const textWidth = ctx.measureText(timeText).width;
1783
- const bgPaddingX = 6;
1784
- const bgPaddingY = 3;
1785
- const textX = 48;
1786
- const textY = timeY;
1787
-
1788
- // Draw background pill
1789
- const bgElevated =
1790
- getComputedStyle(this).getPropertyValue("--bg-elevated").trim() ||
1791
- "rgba(0, 0, 0, 0.7)";
1792
- ctx.fillStyle = bgElevated;
1793
- ctx.beginPath();
1794
- ctx.roundRect(
1795
- textX - textWidth - bgPaddingX,
1796
- textY - 8,
1797
- textWidth + bgPaddingX * 2,
1798
- 16,
1799
- 4,
1800
- );
1801
- ctx.fill();
1802
-
1803
- // Draw current time text in white
1804
- ctx.fillStyle =
1805
- getComputedStyle(this)
1806
- .getPropertyValue("--text-primary")
1807
- .trim() || "rgba(255, 255, 255, 1)";
1808
- ctx.fillText(timeText, textX, textY);
1809
- ctx.restore();
1810
- }
1811
- }
1812
- }
1813
-
1814
- // Draw week number - sticky within visible portion of week
1815
- // Fade out week numbers as we zoom in from 150px to 200px
1816
- const weekNumberOpacity = Math.max(
1817
- 0,
1818
- Math.min(1, 1 - (this.dayHeight - 300) / 50),
1819
- );
1820
- const textMutedForWeek =
1821
- getComputedStyle(this).getPropertyValue("--text-muted").trim() ||
1822
- "rgba(255, 255, 255, 0.4)";
1823
- ctx.fillStyle = textMutedForWeek.replace(
1824
- /[\d.]+\)$/,
1825
- `${0.4 * weekNumberOpacity})`,
1826
- );
1827
- ctx.textAlign = "center";
1828
- const label = `W${week.weekNumber}`;
1829
- const weekTop = y;
1830
- const weekBottom = y + week.height;
1831
- // Clamp label to be visible: at least 14px from top of viewport,
1832
- // but within the week's bounds
1833
- const labelY = Math.max(
1834
- 14,
1835
- Math.min(weekTop + week.height / 2 + 4, weekBottom - 4),
1836
- );
1837
- // Only draw if the label position is within the week's visible area and viewport and opacity is significant
1838
- if (
1839
- labelY >= Math.max(0, weekTop + 4) &&
1840
- labelY <= Math.min(height, weekBottom) &&
1841
- weekNumberOpacity > 0.1
1842
- ) {
1843
- ctx.fillText(label, 30, labelY);
1844
- }
1878
+ // Copy event rects from events layer for hit-testing
1879
+ if (this.eventsLayer) {
1880
+ this.eventRects = this.eventsLayer.eventRects;
1881
+ } else {
1882
+ this.eventRects = [];
1845
1883
  }
1846
1884
 
1847
- // Render events on canvas
1848
- this.renderEventsOnCanvas(ctx, scrollTop, height, dayWidth, visibleWeeks);
1849
-
1850
1885
  this.renderDateLabels();
1851
1886
 
1852
1887
  // Draw sticky weekday labels at top of viewport
@@ -1862,808 +1897,17 @@ export class CalendarViewElement extends LitElement {
1862
1897
  this.renderMinimap();
1863
1898
  }
1864
1899
 
1865
- renderEventsOnCanvas(
1866
- ctx: CanvasRenderingContext2D,
1867
- scrollTop: number,
1868
- height: number,
1869
- dayWidth: number,
1870
- visibleWeeks: WeekInfo[],
1871
- ): void {
1872
- const events = this.events;
1873
- const fontFamily = getComputedStyle(this).fontFamily;
1874
- const viewportBottom = scrollTop + height;
1875
- const showTimeScale = this.dayHeight >= 300;
1876
-
1877
- // Clear event rects for new render
1878
- this.eventRects = [];
1879
-
1880
- if (visibleWeeks.length === 0) return;
1881
-
1882
- // Compute visible date range
1883
- const firstVisibleWeek = visibleWeeks[0]!;
1884
- const lastVisibleWeek = visibleWeeks[visibleWeeks.length - 1]!;
1885
- const firstVisibleDay = firstVisibleWeek.days[0]!;
1886
- const lastVisibleDay = lastVisibleWeek.days[6]!;
1887
- const visibleStartTime = firstVisibleDay.getTime();
1888
- const visibleEndTime = lastVisibleDay.getTime() + 86400000 - 1;
1889
-
1890
- // Track occupied row slots per day-column for stacking
1891
- const dayOccupiedRows = new Map<string, Set<number>>();
1892
- const eventRowIndex = new Map<string, number>();
1893
-
1894
- const segments: EventSegment[] = [];
1895
-
1896
- // Build segments - separate all-day and timed events
1897
- const allDaySegments: EventSegment[] = [];
1898
- const timedSegments: EventSegment[] = [];
1899
-
1900
- for (const event of events) {
1901
- const eventStartTime = event.start.getTime();
1902
- const eventEndTime = event.end.getTime();
1903
-
1904
- if (eventEndTime < visibleStartTime || eventStartTime > visibleEndTime)
1905
- continue;
1906
-
1907
- // Only iterate through visible weeks
1908
- const eventWeeks: { weekIndex: number; week: WeekInfo }[] = [];
1909
-
1910
- for (const week of visibleWeeks) {
1911
- const weekStartDay = week.days[0];
1912
- const weekEndDay = week.days[6];
1913
- if (!weekStartDay || !weekEndDay) continue;
1914
-
1915
- const weekStart = weekStartDay.getTime();
1916
- const weekEnd = weekEndDay.getTime() + 86399999;
1917
-
1918
- if (eventEndTime >= weekStart && eventStartTime <= weekEnd) {
1919
- const weekIndex = this.weeks.indexOf(week);
1920
- eventWeeks.push({ weekIndex, week });
1921
- }
1922
- }
1923
-
1924
- const totalWeeks = eventWeeks.length;
1925
- const isAllDay = event.isAllDay === true;
1926
-
1927
- for (let i = 0; i < eventWeeks.length; i++) {
1928
- const { weekIndex, week } = eventWeeks[i]!;
1929
-
1930
- const isStart = i === 0;
1931
- const isEnd = i === eventWeeks.length - 1;
1932
-
1933
- let startDayIndex = 0;
1934
- let endDayIndex = 6;
1935
-
1936
- if (isStart) {
1937
- startDayIndex = getDayIndexInWeek(week, event.start);
1938
- }
1939
- if (isEnd) {
1940
- // For all-day events, the end date is exclusive (e.g., Monday event ends Tuesday 00:00)
1941
- // Subtract 1ms to get the actual last day
1942
- const effectiveEnd = isAllDay
1943
- ? new Date(event.end.getTime() - 1)
1944
- : event.end;
1945
- endDayIndex = getDayIndexInWeek(week, effectiveEnd);
1946
- }
1947
-
1948
- const segment = {
1949
- event,
1950
- weekIndex,
1951
- week,
1952
- startDayIndex,
1953
- endDayIndex,
1954
- isStart,
1955
- isEnd,
1956
- totalWeeks,
1957
- };
1958
-
1959
- if (isAllDay) {
1960
- allDaySegments.push(segment);
1961
- } else {
1962
- timedSegments.push(segment);
1963
- }
1964
- }
1965
- }
1966
-
1967
- // In timescale mode, split multi-day timed segments into per-day sub-segments
1968
- // so each day renders its own block with correct time positions (like renderVirtualEvent).
1969
- const renderedTimedSegments = showTimeScale
1970
- ? timedSegments.flatMap((seg) => {
1971
- if (seg.startDayIndex === seg.endDayIndex) return [seg];
1972
- const result = [];
1973
- for (let d = seg.startDayIndex; d <= seg.endDayIndex; d++) {
1974
- result.push({
1975
- ...seg,
1976
- startDayIndex: d,
1977
- endDayIndex: d,
1978
- isStart: d === seg.startDayIndex && seg.isStart,
1979
- isEnd: d === seg.endDayIndex && seg.isEnd,
1980
- totalWeeks: 1,
1981
- });
1982
- }
1983
- return result;
1984
- })
1985
- : timedSegments;
1986
-
1987
- // Combine segments with all-day events first (so they get top rows)
1988
- segments.push(...allDaySegments, ...renderedTimedSegments);
1989
-
1990
- // Calculate columns for overlapping events in time scale mode
1991
- interface EventLayout {
1992
- segment: EventSegment;
1993
- yStart: number;
1994
- yEnd: number;
1995
- column: number;
1996
- totalColumns: number;
1997
- }
1998
- const eventLayouts: EventLayout[] = [];
1999
-
2000
- // Group segments by week and day for overlap detection
2001
- const segmentsByWeekDay = new Map<string, EventSegment[]>();
2002
- for (const segment of segments) {
2003
- const allDay = segment.event.isAllDay === true;
2004
-
2005
- // Only do column layout for time-scaled non-all-day events
2006
- if (showTimeScale && !allDay) {
2007
- // For multi-day events in time scale, each day gets its own column calculation
2008
- for (
2009
- let dayIdx = segment.startDayIndex;
2010
- dayIdx <= segment.endDayIndex;
2011
- dayIdx++
2012
- ) {
2013
- const key = `${segment.weekIndex}-${dayIdx}`;
2014
- if (!segmentsByWeekDay.has(key)) {
2015
- segmentsByWeekDay.set(key, []);
2016
- }
2017
- segmentsByWeekDay.get(key)!.push(segment);
2018
- }
2019
- }
2020
- }
2021
-
2022
- // Calculate column assignments for each day's overlapping events
2023
- const segmentColumns = new Map<
2024
- string,
2025
- { column: number; totalColumns: number }
2026
- >();
2027
-
2028
- for (const [dayKey, daySegments] of segmentsByWeekDay) {
2029
- const [weekIndexStr, dayIndexStr] = dayKey.split("-");
2030
- const dayIndex = parseInt(dayIndexStr!);
2031
- const weekIndex = parseInt(weekIndexStr!);
2032
-
2033
- // Calculate time ranges for each segment in this day
2034
- interface SegmentTime {
2035
- segment: EventSegment;
2036
- startMinutes: number;
2037
- endMinutes: number;
2038
- }
2039
- const segmentTimes: SegmentTime[] = [];
2040
-
2041
- for (const seg of daySegments) {
2042
- const week = this.weeks[weekIndex];
2043
- if (!week) continue;
2044
-
2045
- const dayStartTime = new Date(week.days[dayIndex]!).setHours(
2046
- 0,
2047
- 0,
2048
- 0,
2049
- 0,
2050
- );
2051
- const dayEndTime = new Date(week.days[dayIndex]!).setHours(
2052
- 23,
2053
- 59,
2054
- 59,
2055
- 999,
2056
- );
2057
- const eventStartTime = seg.event.start.getTime();
2058
- const eventEndTime = seg.event.end.getTime() - 60000; // Subtract 1 minute for rendering
2059
-
2060
- const effectiveStartTime = Math.max(eventStartTime, dayStartTime);
2061
- const effectiveEndTime = Math.min(eventEndTime, dayEndTime);
2062
-
2063
- const effectiveStart = new Date(effectiveStartTime);
2064
- const effectiveEnd = new Date(effectiveEndTime);
2065
-
2066
- const startMinutes =
2067
- effectiveStart.getHours() * 60 + effectiveStart.getMinutes();
2068
- const endMinutes =
2069
- effectiveEnd.getHours() * 60 + effectiveEnd.getMinutes();
2070
-
2071
- segmentTimes.push({ segment: seg, startMinutes, endMinutes });
2072
- }
2073
-
2074
- // Sort by start time, then by duration (longer first)
2075
- segmentTimes.sort((a, b) => {
2076
- if (a.startMinutes !== b.startMinutes)
2077
- return a.startMinutes - b.startMinutes;
2078
- return b.endMinutes - b.startMinutes - (a.endMinutes - a.startMinutes);
2079
- });
2080
-
2081
- // Assign columns using a greedy algorithm
2082
- const columns: { endMinutes: number }[] = [];
2083
-
2084
- for (const st of segmentTimes) {
2085
- // Find the first column where this event fits
2086
- let columnIndex = 0;
2087
- for (; columnIndex < columns.length; columnIndex++) {
2088
- if (columns[columnIndex]!.endMinutes <= st.startMinutes) {
2089
- // This column is free
2090
- break;
2091
- }
2092
- }
2093
-
2094
- // If no existing column works, create a new one
2095
- if (columnIndex === columns.length) {
2096
- columns.push({ endMinutes: st.endMinutes });
2097
- } else {
2098
- columns[columnIndex]!.endMinutes = st.endMinutes;
2099
- }
2100
-
2101
- const segmentKey = `${st.segment.weekIndex}-${st.segment.event.id}-${dayIndex}`;
2102
- segmentColumns.set(segmentKey, {
2103
- column: columnIndex,
2104
- totalColumns: columns.length,
2105
- });
2106
- }
2107
-
2108
- // Update total columns for all segments in this day
2109
- for (const st of segmentTimes) {
2110
- const segmentKey = `${st.segment.weekIndex}-${st.segment.event.id}-${dayIndex}`;
2111
- const layout = segmentColumns.get(segmentKey);
2112
- if (layout) {
2113
- layout.totalColumns = columns.length;
2114
- }
2115
- }
2116
- }
2117
-
2118
- // Render segments
2119
- for (const segment of segments) {
2120
- const {
2121
- event,
2122
- week,
2123
- weekIndex,
2124
- startDayIndex,
2125
- endDayIndex,
2126
- isStart,
2127
- isEnd,
2128
- totalWeeks,
2129
- } = segment;
2130
- const weekYOffset = week.yOffset;
2131
-
2132
- const allDay = event.isAllDay === true;
2133
-
2134
- // Get visual position for the start day
2135
- const startVisualPos = this.getDayVisualPosition(startDayIndex);
2136
- const endVisualPos = this.getDayVisualPosition(endDayIndex);
2137
-
2138
- // Skip segments that span multiple visual rows for now (would need to be split)
2139
- // For simple case, render each day on its visual row
2140
- if (startVisualPos.row !== endVisualPos.row) {
2141
- // Event spans visual rows - for now, only render the start portion
2142
- // TODO: Split into multiple segments across visual rows
2143
- }
2144
-
2145
- let yStart: number;
2146
- let yEnd: number;
2147
-
2148
- if (showTimeScale && !allDay) {
2149
- const dayStartTime = new Date(week.days[startDayIndex]!).setHours(
2150
- 0,
2151
- 0,
2152
- 0,
2153
- 0,
2154
- );
2155
- const dayEndTime = new Date(week.days[endDayIndex]!).setHours(
2156
- 23,
2157
- 59,
2158
- 59,
2159
- 999,
2160
- );
2161
- const eventStartTime = event.start.getTime();
2162
- const eventEndTime = event.end.getTime() - 60000; // Subtract 1 minute for rendering
2163
-
2164
- const effectiveStartTime = Math.max(eventStartTime, dayStartTime);
2165
- const effectiveEndTime = Math.min(eventEndTime, dayEndTime);
2166
-
2167
- const effectiveStart = new Date(effectiveStartTime);
2168
- const effectiveEnd = new Date(effectiveEndTime);
2169
-
2170
- const startMinutes =
2171
- effectiveStart.getHours() * 60 + effectiveStart.getMinutes();
2172
- const endMinutes =
2173
- effectiveEnd.getHours() * 60 + effectiveEnd.getMinutes();
2174
-
2175
- // Position within the visual row
2176
- const visualRowY = weekYOffset + startVisualPos.row * this.dayHeight;
2177
- yStart = visualRowY + (startMinutes / 1440) * this.dayHeight;
2178
- yEnd = visualRowY + (endMinutes / 1440) * this.dayHeight;
2179
- } else {
2180
- const eventKey = `${weekIndex}-${event.id}`;
2181
- let rowIndex = eventRowIndex.get(eventKey);
2182
-
2183
- if (rowIndex === undefined) {
2184
- rowIndex = 0;
2185
- while (true) {
2186
- let rowFree = true;
2187
- for (let d = startDayIndex; d <= endDayIndex; d++) {
2188
- const dayKey = `${weekIndex}-${d}`;
2189
- const occupied = dayOccupiedRows.get(dayKey);
2190
- if (occupied?.has(rowIndex)) {
2191
- rowFree = false;
2192
- break;
2193
- }
2194
- }
2195
- if (rowFree) break;
2196
- rowIndex++;
2197
- }
2198
- eventRowIndex.set(eventKey, rowIndex);
2199
- }
2200
-
2201
- for (let d = startDayIndex; d <= endDayIndex; d++) {
2202
- const dayKey = `${weekIndex}-${d}`;
2203
- let occupied = dayOccupiedRows.get(dayKey);
2204
- if (!occupied) {
2205
- occupied = new Set();
2206
- dayOccupiedRows.set(dayKey, occupied);
2207
- }
2208
- occupied.add(rowIndex);
2209
- }
2210
-
2211
- const maxEventsInRow = Math.floor(
2212
- (this.dayHeight - 4) / (MIN_EVENT_HEIGHT + 2),
2213
- );
2214
- if (rowIndex >= maxEventsInRow) continue;
2215
-
2216
- // Position within the visual row
2217
- const visualRowY = weekYOffset + startVisualPos.row * this.dayHeight;
2218
- yStart = visualRowY + 4 + rowIndex * (MIN_EVENT_HEIGHT + 2);
2219
- yEnd = yStart + MIN_EVENT_HEIGHT;
2220
- }
2221
-
2222
- const eventHeight = Math.max(
2223
- showTimeScale ? 4 : MIN_EVENT_HEIGHT,
2224
- yEnd - yStart,
2225
- );
2226
-
2227
- // Calculate horizontal position with column layout for time-scaled events
2228
- let x: number;
2229
- let spanWidth: number;
2230
-
2231
- const segmentKey = `${weekIndex}-${event.id}-${startDayIndex}`;
2232
- const columnLayout = segmentColumns.get(segmentKey);
2233
-
2234
- if (
2235
- showTimeScale &&
2236
- !allDay &&
2237
- columnLayout &&
2238
- columnLayout.totalColumns > 1
2239
- ) {
2240
- // This event has parallel events - divide the day width
2241
- const columnWidth = dayWidth / columnLayout.totalColumns;
2242
- x =
2243
- LEFT_GUTTER_WIDTH +
2244
- startVisualPos.col * dayWidth +
2245
- columnLayout.column * columnWidth;
2246
- spanWidth = columnWidth;
2247
- } else {
2248
- // Normal layout - use visual column position
2249
- // For multi-day events on same visual row, calculate span
2250
- const colSpan = endVisualPos.col - startVisualPos.col + 1;
2251
- x = LEFT_GUTTER_WIDTH + startVisualPos.col * dayWidth;
2252
- spanWidth = colSpan * dayWidth;
2253
- }
2254
-
2255
- // Convert to viewport coordinates
2256
- const viewportY = yStart - scrollTop;
2257
-
2258
- // Skip if completely out of view
2259
- if (viewportY + eventHeight < 0 || viewportY > height) continue;
2260
-
2261
- const eventColor = event.color || "#888888";
2262
- const isSelected = this.internal.isEventSelected(event);
2263
- const isHovered = this.hoveredEventId === event.id;
2264
-
2265
- // Apple Calendar style dimensions
2266
- const padding = 2;
2267
- const eventWidth = spanWidth - padding * 2;
2268
- const leftBorderWidth = 3;
2269
- const contentPadding = 6;
2270
-
2271
- // Determine border radius based on span position
2272
- let radiusTopLeft = 4;
2273
- let radiusTopRight = 4;
2274
- let radiusBottomLeft = 4;
2275
- let radiusBottomRight = 4;
2276
-
2277
- if (totalWeeks > 1) {
2278
- if (isStart && !isEnd) {
2279
- radiusTopRight = 0;
2280
- radiusBottomRight = 0;
2281
- } else if (!isStart && isEnd) {
2282
- radiusTopLeft = 0;
2283
- radiusBottomLeft = 0;
2284
- } else if (!isStart && !isEnd) {
2285
- radiusTopLeft = 0;
2286
- radiusTopRight = 0;
2287
- radiusBottomLeft = 0;
2288
- radiusBottomRight = 0;
2289
- }
2290
- }
2291
-
2292
- ctx.save();
2293
-
2294
- // Apply transparency for read-only events
2295
- if (event.readOnly) {
2296
- ctx.globalAlpha = 0.5;
2297
- }
2298
-
2299
- // Draw background - solid color if selected, subtle if not
2300
- const hsl = rgbToHsl(hexToRgb(eventColor));
2301
- const textPrimary =
2302
- getComputedStyle(this).getPropertyValue("--text-primary").trim() ||
2303
- "rgba(255, 255, 255, 0.9)";
2304
- const textInverse =
2305
- getComputedStyle(this).getPropertyValue("--text-inverse").trim() ||
2306
- "rgb(0, 0, 0)";
2307
- // Increase saturation to 75% for more vibrant colors, allow higher lightness for brighter backgrounds
2308
- const backgroundColor = isSelected
2309
- ? eventColor
2310
- : `hsla(${hsl[0]}, ${Math.min(hsl[1] * 0.9, 90)}%, ${Math.min(
2311
- hsl[2] + 15,
2312
- 40,
2313
- )}%, 0.45)`;
2314
- const borderColor = isSelected
2315
- ? eventColor
2316
- : `hsla(${hsl[0]}, ${Math.min(hsl[1] * 0.9, 90)}%, ${Math.min(
2317
- hsl[2] + 10,
2318
- 70,
2319
- )}%, 1)`;
2320
-
2321
- // Check if event should have stripes (not accepted)
2322
- const useStripes = this.shouldRenderEventWithStripes(event);
2323
-
2324
- ctx.beginPath();
2325
- ctx.roundRect(x + padding, viewportY, eventWidth, eventHeight, [
2326
- radiusTopLeft,
2327
- radiusTopRight,
2328
- radiusBottomRight,
2329
- radiusBottomLeft,
2330
- ]);
2331
-
2332
- // Fill with solid background first
2333
- ctx.fillStyle = backgroundColor;
2334
- ctx.fill();
2335
-
2336
- // Add stripe pattern overlay if needed
2337
- if (useStripes) {
2338
- const patternCanvas = this.getStripePatternCanvas();
2339
- if (patternCanvas) {
2340
- ctx.save();
2341
-
2342
- // Clip to the event rectangle
2343
- ctx.beginPath();
2344
- ctx.roundRect(x + padding, viewportY, eventWidth, eventHeight, [
2345
- radiusTopLeft,
2346
- radiusTopRight,
2347
- radiusBottomRight,
2348
- radiusBottomLeft,
2349
- ]);
2350
- ctx.clip();
2351
-
2352
- // Pattern size is 16px (8 * scale of 2)
2353
- const patternSize = 12;
2354
-
2355
- // Calculate starting position to align with content coordinates
2356
- // Start from a position that makes the pattern appear fixed in content space
2357
- const startX = Math.floor((x + padding) / patternSize) * patternSize;
2358
- const startY =
2359
- Math.floor(yStart / patternSize) * patternSize - scrollTop;
2360
-
2361
- // Tile the pattern across the clipped area
2362
- for (
2363
- let py = startY;
2364
- py < viewportY + eventHeight;
2365
- py += patternSize
2366
- ) {
2367
- for (
2368
- let px = startX;
2369
- px < x + padding + eventWidth;
2370
- px += patternSize
2371
- ) {
2372
- ctx.drawImage(patternCanvas, px, py, patternSize, patternSize);
2373
- }
2374
- }
2375
-
2376
- ctx.restore();
2377
- }
2378
- }
2379
-
2380
- // Draw colored left border (Apple Calendar style)
2381
- if (!isSelected) {
2382
- ctx.fillStyle = borderColor;
2383
- ctx.beginPath();
2384
- ctx.roundRect(
2385
- x + padding + 2,
2386
- viewportY + 2,
2387
- leftBorderWidth,
2388
- eventHeight - 4,
2389
- [radiusTopLeft, radiusTopLeft, radiusBottomLeft, radiusBottomLeft],
2390
- );
2391
- ctx.fill();
2392
- }
2393
-
2394
- // Draw selection border - visible outline for selected events
2395
- if (isSelected) {
2396
- ctx.strokeStyle = textInverse;
2397
- ctx.lineWidth = 1;
2398
- ctx.beginPath();
2399
- ctx.roundRect(x + padding, viewportY, eventWidth, eventHeight, [
2400
- radiusTopLeft,
2401
- radiusTopRight,
2402
- radiusBottomRight,
2403
- radiusBottomLeft,
2404
- ]);
2405
- ctx.stroke();
2406
- }
2407
-
2408
- // Draw hover outline
2409
- if (isHovered && !isSelected) {
2410
- ctx.strokeStyle =
2411
- getComputedStyle(this)
2412
- .getPropertyValue("--grid-color-hover")
2413
- .trim() || "rgba(255, 255, 255, 0.2)";
2414
- ctx.lineWidth = 1;
2415
- ctx.beginPath();
2416
- ctx.roundRect(x + padding, viewportY, eventWidth, eventHeight, [
2417
- radiusTopLeft,
2418
- radiusTopRight,
2419
- radiusBottomRight,
2420
- radiusBottomLeft,
2421
- ]);
2422
- ctx.stroke();
2423
- }
2424
-
2425
- // Draw text if this is the start segment and there's enough space
2426
- if (isStart && eventHeight >= 16) {
2427
- // Use inverse text color for selected events (for contrast against solid color)
2428
- // Use event color or primary text color for unselected events
2429
- const textColor = isSelected
2430
- ? textInverse || "white"
2431
- : textPrimary || eventColor;
2432
- ctx.fillStyle = textColor;
2433
- ctx.font = `11px ${fontFamily}`;
2434
- ctx.textAlign = "left";
2435
- ctx.textBaseline = "top";
2436
-
2437
- const textX = x + padding + leftBorderWidth + contentPadding + 1;
2438
- const textY = viewportY + 6;
2439
- let maxTextWidth = eventWidth - leftBorderWidth - contentPadding - 4;
2440
-
2441
- // Clip text to event bounds
2442
- ctx.save();
2443
- ctx.beginPath();
2444
- ctx.rect(
2445
- x + padding + leftBorderWidth,
2446
- viewportY,
2447
- eventWidth - leftBorderWidth,
2448
- eventHeight,
2449
- );
2450
- ctx.clip();
2451
-
2452
- // Draw recurring icon if event has rrule
2453
- if (event.rrule) {
2454
- ctx.font = `11px ${fontFamily}`;
2455
- const recurIcon = "⟳";
2456
- const iconWidth = ctx.measureText(recurIcon).width;
2457
- ctx.fillText(recurIcon, textX, textY);
2458
- maxTextWidth -= iconWidth + 4;
2459
- }
2460
-
2461
- // Draw title with ellipsis if needed
2462
- const titleStartX =
2463
- textX + (event.rrule ? ctx.measureText("⟳").width + 4 : 0);
2464
- let displayTitle = event.title;
2465
- ctx.font = `11px ${fontFamily}`;
2466
- const titleWidth = ctx.measureText(displayTitle).width;
2467
-
2468
- if (titleWidth > maxTextWidth) {
2469
- const ellipsis = "…";
2470
- const ellipsisWidth = ctx.measureText(ellipsis).width;
2471
-
2472
- // Binary search for the right length
2473
- let left = 0;
2474
- let right = displayTitle.length;
2475
- let bestFit = 0;
2476
-
2477
- while (left <= right) {
2478
- const mid = Math.floor((left + right) / 2);
2479
- const testText = displayTitle.substring(0, mid);
2480
- const testWidth = ctx.measureText(testText).width + ellipsisWidth;
2481
-
2482
- if (testWidth <= maxTextWidth) {
2483
- bestFit = mid;
2484
- left = mid + 1;
2485
- } else {
2486
- right = mid - 1;
2487
- }
2488
- }
2489
-
2490
- displayTitle = displayTitle.substring(0, bestFit) + ellipsis;
2491
- }
2492
-
2493
- ctx.fillText(displayTitle, titleStartX, textY);
2494
-
2495
- // Draw time if there's enough space (at least 32px for title + time)
2496
- if (eventHeight >= 32) {
2497
- const formatTime = (date: Date) => {
2498
- const hours = date.getHours();
2499
- const minutes = date.getMinutes();
2500
- const ampm = hours >= 12 ? "PM" : "AM";
2501
- const displayHours = hours % 12 || 12;
2502
- return `${displayHours}:${minutes
2503
- .toString()
2504
- .padStart(2, "0")} ${ampm}`;
2505
- };
2506
-
2507
- const startTime = formatTime(event.start);
2508
- const endTime = formatTime(event.end);
2509
- const timeText = `${startTime} – ${endTime}`;
2510
-
2511
- // Use inverse text color for selected events, primary text or event color for unselected
2512
- const timeTextColor = isSelected
2513
- ? textInverse || "white"
2514
- : textPrimary || eventColor;
2515
- ctx.fillStyle = timeTextColor;
2516
- ctx.font = `10px ${fontFamily}`;
2517
-
2518
- // Draw time with ellipsis if needed
2519
- let displayTime = timeText;
2520
- let timeWidth = ctx.measureText(displayTime).width;
2521
-
2522
- if (timeWidth > maxTextWidth) {
2523
- const ellipsis = "…";
2524
- const ellipsisWidth = ctx.measureText(ellipsis).width;
2525
-
2526
- let left = 0;
2527
- let right = displayTime.length;
2528
- let bestFit = 0;
2529
-
2530
- while (left <= right) {
2531
- const mid = Math.floor((left + right) / 2);
2532
- const testText = displayTime.substring(0, mid);
2533
- const testWidth = ctx.measureText(testText).width + ellipsisWidth;
2534
-
2535
- if (testWidth <= maxTextWidth) {
2536
- bestFit = mid;
2537
- left = mid + 1;
2538
- } else {
2539
- right = mid - 1;
2540
- }
2541
- }
2542
-
2543
- displayTime = displayTime.substring(0, bestFit) + ellipsis;
2544
- }
2545
-
2546
- ctx.fillText(displayTime, textX, textY + 14);
2547
- }
2548
-
2549
- ctx.restore();
2550
- }
2551
-
2552
- ctx.restore();
2553
-
2554
- // Store rect for hit testing (in content coordinates)
2555
- this.eventRects.push({
2556
- event,
2557
- x: x + padding,
2558
- y: yStart,
2559
- width: eventWidth,
2560
- height: eventHeight,
2561
- });
2562
- }
2563
-
2564
- // Render month labels
2565
- const monthNames = [
2566
- "January",
2567
- "February",
2568
- "March",
2569
- "April",
2570
- "May",
2571
- "June",
2572
- "July",
2573
- "August",
2574
- "September",
2575
- "October",
2576
- "November",
2577
- "December",
2578
- ];
2579
-
2580
- const monthBoundaries: {
2581
- monthKey: string;
2582
- monthName: string;
2583
- year: number;
2584
- yOffset: number;
2585
- }[] = [];
2586
- const seenMonths = new Set<string>();
2587
-
2588
- for (const week of visibleWeeks) {
2589
- const firstDay = week.days[0];
2590
- if (!firstDay) continue;
2591
-
2592
- const monthIndex = firstDay.getMonth();
2593
- const year = firstDay.getFullYear();
2594
- const monthKey = `${monthIndex}-${year}`;
2595
-
2596
- if (!seenMonths.has(monthKey)) {
2597
- seenMonths.add(monthKey);
2598
- const monthName = monthNames[monthIndex];
2599
- if (monthName) {
2600
- monthBoundaries.push({
2601
- monthKey,
2602
- monthName,
2603
- year,
2604
- yOffset: week.yOffset,
2605
- });
2606
- }
2607
- }
2608
- }
2609
-
2610
- for (let i = 0; i < monthBoundaries.length; i++) {
2611
- const month = monthBoundaries[i]!;
2612
- const nextMonth = monthBoundaries[i + 1];
2613
- const labelY = month.yOffset;
2614
- const nextMonthY = nextMonth ? nextMonth.yOffset : this.totalHeight;
2615
-
2616
- if (nextMonthY < scrollTop) continue;
2617
- if (labelY > viewportBottom) break;
2618
-
2619
- const padding = [12, 0, 0, 12];
2620
-
2621
- const stickyTop = Math.max(0, scrollTop - labelY);
2622
- const maxStickyTop = nextMonthY - labelY - 24;
2623
- const clampedStickyTop = Math.min(stickyTop, maxStickyTop);
2624
- const labelTopMargin = 32;
2625
- const finalTop = labelY + clampedStickyTop - scrollTop + labelTopMargin;
2626
-
2627
- ctx.save();
2628
- ctx.font = `bold 18px ${fontFamily}`;
2629
- ctx.textAlign = "left";
2630
- ctx.textBaseline = "top";
2631
-
2632
- const labelText = `${month.monthName} ${month.year}`;
2633
- const textWidth = ctx.measureText(labelText).width;
2634
- const leftMargin = 8;
2635
- const textX = 64 + padding[3] + leftMargin;
2636
- const textY = finalTop + padding[0];
2637
-
2638
- // Draw background
2639
- const bgPaddingLeft = 8;
2640
- const bgPaddingRight = 8;
2641
- const bgElevated =
2642
- getComputedStyle(this).getPropertyValue("--bg-elevated").trim() ||
2643
- "rgba(0, 0, 0, 0.7)";
2644
- ctx.fillStyle = bgElevated;
2645
- ctx.beginPath();
2646
- ctx.roundRect(
2647
- textX - bgPaddingLeft,
2648
- textY - 4,
2649
- textWidth + bgPaddingLeft + bgPaddingRight,
2650
- 26,
2651
- 6,
2652
- );
2653
- ctx.fill();
2654
-
2655
- // Draw text
2656
- const textPrimary =
2657
- getComputedStyle(this).getPropertyValue("--text-primary").trim() ||
2658
- "rgba(255, 255, 255, 0.95)";
2659
- ctx.fillStyle = textPrimary;
2660
- ctx.fillText(labelText, textX, textY);
2661
- ctx.restore();
2662
- }
1900
+ toggleLayer(name: string): void {
1901
+ const layer = this.layers.find((l) => l.name === name);
1902
+ if (!layer) throw new Error(`Layer "${name}" not found`);
1903
+ layer.enabled = !layer.enabled;
1904
+ this.renderCanvas();
2663
1905
  }
2664
1906
 
1907
+
2665
1908
  onWheel = (e: WheelEvent): void => {
2666
1909
  if (this.hasAttribute("scroll-lock")) return;
1910
+ this.lastWheelTime = Date.now();
2667
1911
 
2668
1912
  if (this.scrollAnimationFrame) {
2669
1913
  this.cancelScrollAnimation();
@@ -3051,9 +2295,10 @@ export class CalendarViewElement extends LitElement {
3051
2295
  snappedEnd.setTime(snappedStart.getTime() + 15 * 60 * 1000);
3052
2296
  }
3053
2297
 
2298
+ const isZoomedOut = this.dayHeight < TIME_SCALE_DAY_HEIGHT;
3054
2299
  this.dispatchEvent(
3055
2300
  new CustomEvent("create-event", {
3056
- detail: { start: snappedStart, end: snappedEnd },
2301
+ detail: { start: snappedStart, end: snappedEnd, isAllDay: isZoomedOut },
3057
2302
  bubbles: true,
3058
2303
  }),
3059
2304
  );
@@ -3119,6 +2364,9 @@ export class CalendarViewElement extends LitElement {
3119
2364
  this.resizingOriginalStart = null;
3120
2365
  this.resizingOriginalEnd = null;
3121
2366
  this.isResizingEvent = false;
2367
+ if (this.scrollContainer) {
2368
+ this.scrollContainer.style.cursor = "";
2369
+ }
3122
2370
  this.renderCanvas();
3123
2371
  this.requestUpdate();
3124
2372
  }
@@ -3138,6 +2386,7 @@ export class CalendarViewElement extends LitElement {
3138
2386
  // Close event detail overlay
3139
2387
  this.selectedEventForDetail = null;
3140
2388
  this.selectedEventRect = null;
2389
+ this.clearDescriptionSummary();
3141
2390
 
3142
2391
  if (hadSelection) {
3143
2392
  this.dispatchEvent(
@@ -3188,6 +2437,7 @@ export class CalendarViewElement extends LitElement {
3188
2437
  this.internal.clearSelection();
3189
2438
  this.selectedEventForDetail = null;
3190
2439
  this.selectedEventRect = null;
2440
+ this.clearDescriptionSummary();
3191
2441
  this.dispatchEvent(
3192
2442
  new CustomEvent("selection-change", {
3193
2443
  detail: { selectedEvents: [] },
@@ -3220,6 +2470,7 @@ export class CalendarViewElement extends LitElement {
3220
2470
  this.selectedEventForDetail = null;
3221
2471
  this.selectedEventRect = null;
3222
2472
  this.isDescriptionExpanded = false;
2473
+ this.clearDescriptionSummary();
3223
2474
  this.renderCanvas();
3224
2475
  this.requestUpdate();
3225
2476
  this.dispatchEvent(
@@ -3241,16 +2492,17 @@ export class CalendarViewElement extends LitElement {
3241
2492
  // Update cursor position for status bar
3242
2493
  this.cursorPosition = { x, y };
3243
2494
 
3244
- // Check for resize handles first
3245
- const resizeHandle = this.getResizeHandle(x, y);
2495
+ // Check for resize handles first — suppressed when alt key is active
2496
+ const resizeHandle = !e.altKey ? this.getResizeHandle(x, y) : null;
3246
2497
  if (resizeHandle && !this.isResizingEvent && !this.isDraggingEvent) {
3247
- this.scrollContainer.style.cursor = "ns-resize";
2498
+ this.scrollContainer.style.cursor =
2499
+ this.dayHeight >= TIME_SCALE_DAY_HEIGHT ? "ns-resize" : "ew-resize";
3248
2500
  } else if (!this.isResizingEvent && !this.isDraggingEvent) {
3249
2501
  this.scrollContainer.style.cursor = "";
3250
2502
  }
3251
2503
 
3252
- // Check for event hover
3253
- const hoveredEvent = this.getEventAtPosition(x, y);
2504
+ // Check for event hover — suppressed when alt key is active
2505
+ const hoveredEvent = !e.altKey ? this.getEventAtPosition(x, y) : null;
3254
2506
  const newHoveredId = hoveredEvent ? hoveredEvent.id : null;
3255
2507
 
3256
2508
  if (newHoveredId !== this.hoveredEventId) {
@@ -3383,11 +2635,10 @@ export class CalendarViewElement extends LitElement {
3383
2635
  } else {
3384
2636
  // Move event
3385
2637
  this.dispatchEvent(
3386
- new CustomEvent("move-event", {
2638
+ new CustomEvent("update-event", {
3387
2639
  detail: {
3388
2640
  event: this.movingEvent,
3389
- start: newStart,
3390
- end: newEnd,
2641
+ updates: { start: newStart, end: newEnd },
3391
2642
  },
3392
2643
  bubbles: true,
3393
2644
  }),
@@ -3408,6 +2659,31 @@ export class CalendarViewElement extends LitElement {
3408
2659
  }
3409
2660
  };
3410
2661
 
2662
+ onKeyDown = (e: KeyboardEvent): void => {
2663
+ const focused = e.composedPath()[0] as HTMLElement;
2664
+ if (focused?.tagName === "INPUT" || focused?.tagName === "TEXTAREA" || focused?.isContentEditable) return;
2665
+ if (e.altKey && !this.altKeyActive) {
2666
+ this.altKeyActive = true;
2667
+ this.dispatchEvent(new CustomEvent("meta-key-change", { bubbles: true }));
2668
+ if (this.scrollContainer) {
2669
+ this.scrollContainer.style.cursor = "";
2670
+ }
2671
+ if (this.hoveredEventId !== null) {
2672
+ this.hoveredEventId = null;
2673
+ this.renderCanvas();
2674
+ }
2675
+ }
2676
+ };
2677
+
2678
+ onKeyUp = (e: KeyboardEvent): void => {
2679
+ const focused = e.composedPath()[0] as HTMLElement;
2680
+ if (focused?.tagName === "INPUT" || focused?.tagName === "TEXTAREA" || focused?.isContentEditable) return;
2681
+ if (!e.altKey && this.altKeyActive) {
2682
+ this.altKeyActive = false;
2683
+ this.dispatchEvent(new CustomEvent("meta-key-change", { bubbles: true }));
2684
+ }
2685
+ };
2686
+
3411
2687
  onPaste = async (e: ClipboardEvent): Promise<void> => {
3412
2688
  const files = e.clipboardData?.files;
3413
2689
  if (files && files.length > 0) {
@@ -3522,6 +2798,7 @@ export class CalendarViewElement extends LitElement {
3522
2798
  y: number,
3523
2799
  ): { event: CalendarEvent; edge: "start" | "end" } | null {
3524
2800
  const RESIZE_HANDLE_SIZE = 8; // pixels from edge to detect resize
2801
+ const isZoomedIn = this.dayHeight >= TIME_SCALE_DAY_HEIGHT;
3525
2802
 
3526
2803
  // Check in reverse order (top to bottom rendering)
3527
2804
  for (let i = this.eventRects.length - 1; i >= 0; i--) {
@@ -3531,18 +2808,33 @@ export class CalendarViewElement extends LitElement {
3531
2808
  // Skip read-only events
3532
2809
  if (rect.event.readOnly) continue;
3533
2810
 
3534
- // Check if within horizontal bounds
3535
- if (x < rect.x || x > rect.x + rect.width) continue;
2811
+ if (isZoomedIn) {
2812
+ // In time-scale view, resize vertically from the top/bottom edges.
2813
+ if (x < rect.x || x > rect.x + rect.width) continue;
2814
+
2815
+ if (y >= rect.y && y <= rect.y + RESIZE_HANDLE_SIZE) {
2816
+ return { event: rect.event, edge: "start" };
2817
+ }
2818
+
2819
+ if (
2820
+ y >= rect.y + rect.height - RESIZE_HANDLE_SIZE &&
2821
+ y <= rect.y + rect.height
2822
+ ) {
2823
+ return { event: rect.event, edge: "end" };
2824
+ }
2825
+ continue;
2826
+ }
3536
2827
 
3537
- // Check top edge (resize start)
3538
- if (y >= rect.y && y <= rect.y + RESIZE_HANDLE_SIZE) {
2828
+ // In zoomed-out view, resize horizontally from the left/right edges.
2829
+ if (y < rect.y || y > rect.y + rect.height) continue;
2830
+
2831
+ if (x >= rect.x && x <= rect.x + RESIZE_HANDLE_SIZE) {
3539
2832
  return { event: rect.event, edge: "start" };
3540
2833
  }
3541
2834
 
3542
- // Check bottom edge (resize end)
3543
2835
  if (
3544
- y >= rect.y + rect.height - RESIZE_HANDLE_SIZE &&
3545
- y <= rect.y + rect.height
2836
+ x >= rect.x + rect.width - RESIZE_HANDLE_SIZE &&
2837
+ x <= rect.x + rect.width
3546
2838
  ) {
3547
2839
  return { event: rect.event, edge: "end" };
3548
2840
  }
@@ -3582,6 +2874,8 @@ export class CalendarViewElement extends LitElement {
3582
2874
  this.resizingOriginalStart = new Date(resizeHandle.event.start);
3583
2875
  this.resizingOriginalEnd = new Date(resizeHandle.event.end);
3584
2876
  this.isResizingEvent = false; // Will be set to true on first mouse move
2877
+ this.scrollContainer.style.cursor =
2878
+ this.dayHeight >= TIME_SCALE_DAY_HEIGHT ? "ns-resize" : "ew-resize";
3585
2879
  return;
3586
2880
  }
3587
2881
 
@@ -3604,6 +2898,7 @@ export class CalendarViewElement extends LitElement {
3604
2898
  // Close event detail overlay
3605
2899
  this.selectedEventForDetail = null;
3606
2900
  this.selectedEventRect = null;
2901
+ this.clearDescriptionSummary();
3607
2902
 
3608
2903
  this.movingEvent = clickedEvent;
3609
2904
  this.movingEventOrigin = { x, y };
@@ -3619,6 +2914,7 @@ export class CalendarViewElement extends LitElement {
3619
2914
  // Close event detail overlay when clicking outside of events
3620
2915
  this.selectedEventForDetail = null;
3621
2916
  this.selectedEventRect = null;
2917
+ this.clearDescriptionSummary();
3622
2918
 
3623
2919
  // Clear selection when clicking on empty space
3624
2920
  const hadSelection = this.internal.getSelectedEvents().length > 0;
@@ -3653,13 +2949,13 @@ export class CalendarViewElement extends LitElement {
3653
2949
  *
3654
2950
  * Selection box is 2D with different behavior based on zoom level:
3655
2951
  *
3656
- * ZOOMED OUT (dayHeight < 200):
2952
+ * ZOOMED OUT (dayHeight < TIME_SCALE_DAY_HEIGHT):
3657
2953
  * - X axis: days of the week (horizontal)
3658
2954
  * - Y axis: multiple weeks stacked (vertical)
3659
2955
  * - Creates time ranges per WEEK for selected day range
3660
2956
  * - Example: Wed-Thu across 3 weeks = 3 ranges (one per week)
3661
2957
  *
3662
- * ZOOMED IN (dayHeight >= 200, time scale visible):
2958
+ * ZOOMED IN (dayHeight >= TIME_SCALE_DAY_HEIGHT, time scale visible):
3663
2959
  * - X axis: days of the week (horizontal)
3664
2960
  * - Y axis: time within days (vertical, with weeks)
3665
2961
  * - Creates time ranges per DAY for selected time range
@@ -3705,7 +3001,7 @@ export class CalendarViewElement extends LitElement {
3705
3001
  if (intersectingWeeks.length === 0) return;
3706
3002
 
3707
3003
  const timeRanges: Array<{ start: Date; end: Date }> = [];
3708
- const isZoomedIn = this.dayHeight >= 200;
3004
+ const isZoomedIn = this.dayHeight >= TIME_SCALE_DAY_HEIGHT;
3709
3005
 
3710
3006
  if (isZoomedIn) {
3711
3007
  // ZOOMED IN: Create time ranges per DAY (same time across multiple weeks)
@@ -3794,6 +3090,10 @@ export class CalendarViewElement extends LitElement {
3794
3090
  for (const event of this.events) {
3795
3091
  // Skip all-day events
3796
3092
  if (event.isAllDay) continue;
3093
+ if (event.visualStyle === "heatmap") continue;
3094
+
3095
+ // Only select events from the active calendar
3096
+ if (this.activeCalendarId && event.calendarId !== this.activeCalendarId && event.sourceId !== this.activeCalendarId) continue;
3797
3097
 
3798
3098
  const eventStartTime = event.start.getTime();
3799
3099
  const eventEndTime = event.end.getTime();
@@ -3822,6 +3122,7 @@ export class CalendarViewElement extends LitElement {
3822
3122
  // Close event detail overlay when replacing selection
3823
3123
  this.selectedEventForDetail = null;
3824
3124
  this.selectedEventRect = null;
3125
+ this.clearDescriptionSummary();
3825
3126
  }
3826
3127
  for (const event of selectedEvents) {
3827
3128
  this.internal.selectEvent(event, "add");
@@ -3885,9 +3186,17 @@ export class CalendarViewElement extends LitElement {
3885
3186
  const scrollTop = this.scrollTop;
3886
3187
  const scrollBottom = scrollTop + this.viewportHeight;
3887
3188
 
3189
+ const nearTop = scrollTop < bufferHeight;
3190
+ const nearBottom = scrollBottom > this.totalHeight - bufferHeight;
3191
+ if (!nearTop && !nearBottom) return;
3192
+
3888
3193
  // Check if near top (extending to past)
3889
- if (scrollTop < bufferHeight) {
3194
+ if (nearTop) {
3890
3195
  this.isExtendingRange = true;
3196
+ // Capture before extension: only compensate for wheel/touch scroll.
3197
+ // For scrollbar drags, skip compensation so the thumb stays where the user
3198
+ // put it and they see the newly loaded historical content — no treadmill.
3199
+ const isWheelScroll = Date.now() - this.lastWheelTime < 200;
3891
3200
  const newWeeks = this.internal.extendRange("past");
3892
3201
 
3893
3202
  // Prepend new weeks
@@ -3906,11 +3215,13 @@ export class CalendarViewElement extends LitElement {
3906
3215
  // Must update DOM first so scroll container has correct height
3907
3216
  this.requestUpdate();
3908
3217
 
3909
- // Adjust scroll position to maintain visual position (after DOM update)
3910
3218
  requestAnimationFrame(() => {
3911
- this._scrollTop += addedHeight;
3912
- if (this.scrollContainer) {
3913
- this.scrollContainer.scrollTop = this._scrollTop;
3219
+ if (isWheelScroll) {
3220
+ // Maintain visual position so content doesn't jump during wheel scroll
3221
+ this._scrollTop += addedHeight;
3222
+ if (this.scrollContainer) {
3223
+ this.scrollContainer.scrollTop = this._scrollTop;
3224
+ }
3914
3225
  }
3915
3226
 
3916
3227
  this.renderCanvas();
@@ -3921,7 +3232,7 @@ export class CalendarViewElement extends LitElement {
3921
3232
  }
3922
3233
 
3923
3234
  // Check if near bottom (extending to future)
3924
- if (scrollBottom > this.totalHeight - bufferHeight) {
3235
+ if (nearBottom) {
3925
3236
  this.isExtendingRange = true;
3926
3237
  const newWeeks = this.internal.extendRange("future");
3927
3238
 
@@ -4021,6 +3332,7 @@ export class CalendarViewElement extends LitElement {
4021
3332
 
4022
3333
  // Show event detail overlay for single selection
4023
3334
  if (!isCmdOrCtrl) {
3335
+ this.clearDescriptionSummary();
4024
3336
  this.selectedEventForDetail = event;
4025
3337
  this.isDescriptionExpanded = false;
4026
3338
  // Find the event's rect and store it for positioning
@@ -4040,6 +3352,7 @@ export class CalendarViewElement extends LitElement {
4040
3352
  this.selectedEventForDetail = null;
4041
3353
  this.selectedEventRect = null;
4042
3354
  this.isDescriptionExpanded = false;
3355
+ this.clearDescriptionSummary();
4043
3356
  this.requestUpdate();
4044
3357
  }
4045
3358
 
@@ -4101,6 +3414,7 @@ export class CalendarViewElement extends LitElement {
4101
3414
  // Close event detail overlay when clearing selection
4102
3415
  this.selectedEventForDetail = null;
4103
3416
  this.selectedEventRect = null;
3417
+ this.clearDescriptionSummary();
4104
3418
 
4105
3419
  if (hadSelection) {
4106
3420
  this.dispatchEvent(
@@ -4194,6 +3508,7 @@ export class CalendarViewElement extends LitElement {
4194
3508
  fill: accent.replace("rgb", "rgba").replace(")", ", 0.3)"),
4195
3509
  stroke: accent.replace("rgb", "rgba").replace(")", ", 0.8)"),
4196
3510
  text: "white",
3511
+ dashed: false,
4197
3512
  },
4198
3513
  useStartEdge,
4199
3514
  );
@@ -4206,6 +3521,7 @@ export class CalendarViewElement extends LitElement {
4206
3521
  fill: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.5)`,
4207
3522
  stroke: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.5)`,
4208
3523
  text: "rgba(255, 255, 255, 0.5)",
3524
+ dashed: false,
4209
3525
  },
4210
3526
  useStartEdge,
4211
3527
  );
@@ -4250,8 +3566,13 @@ export class CalendarViewElement extends LitElement {
4250
3566
  later = startDate < endDate ? endDate : startDate;
4251
3567
  }
4252
3568
 
4253
- earlier.setMinutes(Math.round(earlier.getMinutes() / 15) * 15, 0, 0);
4254
- later.setMinutes(Math.round(later.getMinutes() / 15) * 15, 0, 0);
3569
+ if (this.dayHeight < TIME_SCALE_DAY_HEIGHT) {
3570
+ earlier.setHours(0, 0, 0, 0);
3571
+ later.setHours(23, 59, 59, 999);
3572
+ } else {
3573
+ earlier.setMinutes(Math.round(earlier.getMinutes() / 15) * 15, 0, 0);
3574
+ later.setMinutes(Math.round(later.getMinutes() / 15) * 15, 0, 0);
3575
+ }
4255
3576
 
4256
3577
  // Use active calendar color if available, otherwise fall back to accent-primary
4257
3578
  let fill: string;
@@ -4279,7 +3600,7 @@ export class CalendarViewElement extends LitElement {
4279
3600
  renderVirtualEvent(
4280
3601
  start: Date,
4281
3602
  end: Date,
4282
- color: { fill: string; stroke: string; text: string },
3603
+ color: { fill: string; stroke: string; text: string; dashed?: boolean },
4283
3604
  useStartEdge = true,
4284
3605
  ): void {
4285
3606
  if (!this.overlayCanvas || !this.overlayCtx || !this.scrollContainer)
@@ -4329,7 +3650,7 @@ export class CalendarViewElement extends LitElement {
4329
3650
  ctx.fill();
4330
3651
  ctx.strokeStyle = color.stroke;
4331
3652
  ctx.lineWidth = 1;
4332
- ctx.setLineDash([6, 3]);
3653
+ if (color.dashed !== false) ctx.setLineDash([6, 3]);
4333
3654
  ctx.stroke();
4334
3655
  ctx.setLineDash([]);
4335
3656
  };
@@ -4813,7 +4134,7 @@ export class CalendarViewElement extends LitElement {
4813
4134
  let hour = 0;
4814
4135
  let minute = 0;
4815
4136
 
4816
- if (this.dayHeight >= 200) {
4137
+ if (this.dayHeight >= TIME_SCALE_DAY_HEIGHT) {
4817
4138
  const offsetInRow = y - (week.yOffset + rowInWeek * this.dayHeight);
4818
4139
  const minutes = Math.floor((offsetInRow / this.dayHeight) * 24 * 60);
4819
4140
  hour = Math.floor(minutes / 60);
@@ -4897,6 +4218,7 @@ export class CalendarViewElement extends LitElement {
4897
4218
  formattedCursorDate: cursorTime
4898
4219
  ? this.formatCompactDate(cursorTime.date)
4899
4220
  : "",
4221
+ altKeyActive: this.altKeyActive,
4900
4222
  };
4901
4223
  }
4902
4224
 
@@ -4940,6 +4262,7 @@ export class CalendarViewElement extends LitElement {
4940
4262
 
4941
4263
  this.selectedEventForDetail = null;
4942
4264
  this.selectedEventRect = null;
4265
+ this.clearDescriptionSummary();
4943
4266
  this.requestUpdate();
4944
4267
  }
4945
4268
 
@@ -5079,6 +4402,31 @@ export class CalendarViewElement extends LitElement {
5079
4402
  }
5080
4403
 
5081
4404
  const event = this.selectedEventForDetail;
4405
+ const eventDescription = sanitizeEventDescription(event.description ?? "");
4406
+ const eventDescriptionText = eventDescription.replaceAll("\\n", "\n");
4407
+ const useDescriptionSummary = eventDescription.trim().length > 200;
4408
+ const currentSummaryKey = this.getDescriptionSummaryTargetKey(event);
4409
+ const hasRequestedSummaryForCurrentEvent =
4410
+ this.requestedDescriptionSummaryKey === currentSummaryKey;
4411
+ const isSummaryForCurrentEvent =
4412
+ this.descriptionSummaryTargetKey === currentSummaryKey;
4413
+ const hasSummaryStateForCurrentEvent =
4414
+ hasRequestedSummaryForCurrentEvent || isSummaryForCurrentEvent;
4415
+ const streamedSummaryText = hasSummaryStateForCurrentEvent
4416
+ ? this.descriptionSummaryText
4417
+ : "";
4418
+ const isSummaryLoading =
4419
+ useDescriptionSummary && hasSummaryStateForCurrentEvent && this.descriptionSummaryLoading;
4420
+ const shouldRenderSummary =
4421
+ useDescriptionSummary && hasSummaryStateForCurrentEvent;
4422
+ const descriptionToRender = shouldRenderSummary
4423
+ ? streamedSummaryText || (isSummaryLoading ? "Generating summary..." : eventDescriptionText)
4424
+ : eventDescriptionText;
4425
+ const summarizeButtonLabel = isSummaryLoading
4426
+ ? "Summarizing..."
4427
+ : shouldRenderSummary
4428
+ ? "Summarize again"
4429
+ : "Summarize";
5082
4430
 
5083
4431
  const formatDate = (date: Date) => {
5084
4432
  return new Intl.DateTimeFormat(this.locale, {
@@ -5217,6 +4565,7 @@ export class CalendarViewElement extends LitElement {
5217
4565
  this.selectedEventForDetail = null;
5218
4566
  this.selectedEventRect = null;
5219
4567
  this.isDescriptionExpanded = false;
4568
+ this.clearDescriptionSummary();
5220
4569
  this.requestUpdate();
5221
4570
  }}
5222
4571
  >×</button>
@@ -5224,7 +4573,7 @@ export class CalendarViewElement extends LitElement {
5224
4573
 
5225
4574
  <div class="event-detail-body">
5226
4575
  ${(() => {
5227
- const teamsMatch = event.description?.match(/https:\/\/teams\.microsoft\.com\/[^\s<>"]+/);
4576
+ const teamsMatch = eventDescription?.match(/https:\/\/teams\.microsoft\.com\/[^\s<>"]+/);
5228
4577
  const teamsUrl = teamsMatch ? teamsMatch[0] : null;
5229
4578
  if (!event.location && !teamsUrl) return null;
5230
4579
  return html`
@@ -5327,47 +4676,84 @@ export class CalendarViewElement extends LitElement {
5327
4676
  }
5328
4677
 
5329
4678
  ${
5330
- event.readOnly ||
5331
- (event.organizer != null &&
5332
- !this.currentUserEmails.has(event.organizer.email))
5333
- ? event.description
4679
+ useDescriptionSummary
5334
4680
  ? html`
5335
4681
  <div class="event-detail-section">
5336
4682
  <div class="event-detail-label">Description</div>
5337
4683
  <div class="event-detail-description ${
5338
- this.isDescriptionExpanded ? "expanded" : ""
4684
+ shouldRenderSummary || this.isDescriptionExpanded
4685
+ ? "expanded"
4686
+ : ""
5339
4687
  }">
5340
- <pre class="event-detail-value">${event.description.replaceAll(
5341
- "\\n",
5342
- "\n",
5343
- )}</pre>
4688
+ <pre class="event-detail-value">${descriptionToRender}</pre>
4689
+ </div>
4690
+ ${
4691
+ shouldRenderSummary && this.descriptionSummaryError
4692
+ ? html`<div class="event-detail-value" style="opacity: 0.7;">Summary unavailable: ${this.descriptionSummaryError}. Showing original description.</div>`
4693
+ : null
4694
+ }
4695
+ <div class="description-actions">
4696
+ ${
4697
+ !shouldRenderSummary
4698
+ ? html`<button
4699
+ class="description-see-more"
4700
+ @click=${() => {
4701
+ this.isDescriptionExpanded = !this.isDescriptionExpanded;
4702
+ this.requestUpdate();
4703
+ }}
4704
+ >
4705
+ ${this.isDescriptionExpanded ? "Show less" : "Show more"}
4706
+ </button>`
4707
+ : null
4708
+ }
4709
+ <button
4710
+ class="description-see-more"
4711
+ ?disabled=${isSummaryLoading}
4712
+ @click=${() => this.requestDescriptionSummary(event)}
4713
+ >
4714
+ ${summarizeButtonLabel}
4715
+ </button>
4716
+ </div>
4717
+ </div>
4718
+ `
4719
+ : event.readOnly ||
4720
+ (event.organizer != null &&
4721
+ !this.currentUserEmails.has(event.organizer.email))
4722
+ ? eventDescription
4723
+ ? html`
4724
+ <div class="event-detail-section">
4725
+ <div class="event-detail-label">Description</div>
4726
+ <div class="event-detail-description ${
4727
+ this.isDescriptionExpanded ? "expanded" : ""
4728
+ }">
4729
+ <pre class="event-detail-value">${descriptionToRender}</pre>
5344
4730
  </div>
5345
4731
  ${
5346
- event.description.length > 300 ||
5347
- event.description.split("\n").length > 8
5348
- ? html`
4732
+ eventDescription.length > 300 ||
4733
+ eventDescription.split("\n").length > 8
4734
+ ? html`
5349
4735
  <button
5350
4736
  class="description-see-more"
5351
4737
  @click=${() => {
5352
- this.isDescriptionExpanded =
5353
- !this.isDescriptionExpanded;
5354
- this.requestUpdate();
5355
- }}
4738
+ this.isDescriptionExpanded =
4739
+ !this.isDescriptionExpanded;
4740
+ this.requestUpdate();
4741
+ }}
5356
4742
  >
5357
4743
  ${this.isDescriptionExpanded ? "See less" : "See more"}
5358
4744
  </button>
5359
4745
  `
5360
- : null
5361
- }
4746
+ : null
4747
+ }
5362
4748
  </div>
5363
4749
  `
5364
- : null
5365
- : html`
4750
+ : null
4751
+ : html`
5366
4752
  <div class="event-detail-section">
5367
4753
  <div class="event-detail-label">Description</div>
5368
4754
  <textarea
5369
4755
  class="event-detail-description-input"
5370
- .value=${event.description ?? ""}
4756
+ .value=${eventDescription}
5371
4757
  placeholder="Add description..."
5372
4758
  rows="3"
5373
4759
  @input=${(e: Event) => {
@@ -5397,7 +4783,7 @@ export class CalendarViewElement extends LitElement {
5397
4783
  clearTimeout(this.updateEventTimeout);
5398
4784
  this.updateEventTimeout = null;
5399
4785
  }
5400
- if (newDescription !== (event.description ?? "")) {
4786
+ if (newDescription !== eventDescription) {
5401
4787
  this.dispatchEvent(
5402
4788
  new CustomEvent("update-event", {
5403
4789
  detail: {
@@ -5635,18 +5021,3 @@ export class CalendarViewElement extends LitElement {
5635
5021
  `;
5636
5022
  }
5637
5023
  }
5638
-
5639
- // Helper to get day index (0-6) within a week for a given date
5640
- function getDayIndexInWeek(week: WeekInfo, date: Date) {
5641
- const dateStart = new Date(date).setHours(0, 0, 0, 0);
5642
- for (let i = 0; i < 7; i++) {
5643
- const weekDay = week.days[i];
5644
- if (weekDay && new Date(weekDay).setHours(0, 0, 0, 0) === dateStart) {
5645
- return i;
5646
- }
5647
- }
5648
- // Date is before or after this week
5649
- const weekStart = new Date(week.days[0]!).setHours(0, 0, 0, 0);
5650
- if (dateStart < weekStart) return 0;
5651
- return 6;
5652
- }