@luckydye/calendar 1.3.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,15 @@ 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 { TIME_SCALE_DAY_HEIGHT, type CalendarLayer, type LayerContext } from "./CalendarLayer.js";
22
+ import { createGridLayer } from "./layers/GridLayer.js";
23
+ import { createEventsLayer, type EventRect, type EventsState } from "./layers/EventsLayer.js";
24
+ import { createTimeseriesHeatmapLayer } from "./layers/TimeseriesHeatmapLayer.js";
22
25
 
23
- const MIN_DAY_HEIGHT = 100;
26
+ const MIN_DAY_HEIGHT = 50;
24
27
  const MAX_DAY_HEIGHT = 3000; // 1px per minute
25
28
  const LEFT_GUTTER_WIDTH = 60;
26
29
  const MINIMAP_WIDTH = 12;
27
- const MIN_EVENT_HEIGHT = 20;
28
30
 
29
31
  export class CalendarViewElement extends LitElement {
30
32
  static styles = css`
@@ -854,6 +856,14 @@ export class CalendarViewElement extends LitElement {
854
856
  minimapBufferCanvas: HTMLCanvasElement | null = null;
855
857
  minimapBufferCtx: CanvasRenderingContext2D | null = null;
856
858
  stripePatternCanvas: HTMLCanvasElement | null = null;
859
+ layers: CalendarLayer[] = [];
860
+ eventsLayer: ReturnType<typeof createEventsLayer> | null = null;
861
+ heatmapEvents: CalendarEvent[] = [];
862
+ heatmapRange: { start: Date; end: Date } | null = null;
863
+ heatmapQueryKey: string | null = null;
864
+ heatmapQueryToken = 0;
865
+ timeseriesSourceIds: Set<string> = new Set();
866
+ timeseriesSourceCacheKey: string | null = null;
857
867
  scrollContainer: HTMLElement | null = null;
858
868
  scrollContent: HTMLElement | null = null;
859
869
  resizeObserver: ResizeObserver | null = null;
@@ -875,9 +885,12 @@ export class CalendarViewElement extends LitElement {
875
885
  cursorPosition: { x: number; y: number } | null = null; // Mouse position in calendar coordinates
876
886
  animationFrame: number | null = null;
877
887
  isDraggingMinimap = false;
888
+ altKeyActive = false; // Tracks if alt/meta key is held (bypasses event interaction)
878
889
  isFiltered = false; // Tracks if filter is currently active
879
890
  timeUpdateInterval: ReturnType<typeof setInterval> | null = null;
880
891
  isExtendingRange = false; // Prevents concurrent range extensions
892
+ boundaryCheckInterval: ReturnType<typeof setInterval> | null = null;
893
+ lastWheelTime = 0; // Timestamp of last wheel event, used to distinguish wheel vs scrollbar drag
881
894
  isCreatingEvent = false;
882
895
  eventCreationStart: { x: number; y: number } | null = null;
883
896
  eventCreationEnd: { x: number; y: number } | null = null;
@@ -992,6 +1005,7 @@ export class CalendarViewElement extends LitElement {
992
1005
  currentTheme: ThemeName = loadThemePreference();
993
1006
 
994
1007
  activeCalendarColor: string | null = null;
1008
+ activeCalendarId: string | null = null;
995
1009
 
996
1010
  get filter() {
997
1011
  return this.internal.getFilter();
@@ -1302,6 +1316,8 @@ export class CalendarViewElement extends LitElement {
1302
1316
  window.addEventListener("mousemove", this.onMouseMove);
1303
1317
  window.addEventListener("mouseup", this.onMouseUp);
1304
1318
  window.addEventListener("paste", this.onPaste);
1319
+ window.addEventListener("keydown", this.onKeyDown);
1320
+ window.addEventListener("keyup", this.onKeyUp);
1305
1321
  }
1306
1322
 
1307
1323
  disconnectedCallback() {
@@ -1310,6 +1326,8 @@ export class CalendarViewElement extends LitElement {
1310
1326
  window.removeEventListener("mousemove", this.onMouseMove);
1311
1327
  window.removeEventListener("mouseup", this.onMouseUp);
1312
1328
  window.removeEventListener("paste", this.onPaste);
1329
+ window.removeEventListener("keydown", this.onKeyDown);
1330
+ window.removeEventListener("keyup", this.onKeyUp);
1313
1331
 
1314
1332
  if (this.scrollContainer) {
1315
1333
  this.scrollContainer.removeEventListener(
@@ -1326,6 +1344,9 @@ export class CalendarViewElement extends LitElement {
1326
1344
  if (this.timeUpdateInterval) {
1327
1345
  clearInterval(this.timeUpdateInterval);
1328
1346
  }
1347
+ if (this.boundaryCheckInterval) {
1348
+ clearInterval(this.boundaryCheckInterval);
1349
+ }
1329
1350
  }
1330
1351
 
1331
1352
  onScrollContainerMouseLeave = (): void => {
@@ -1367,6 +1388,24 @@ export class CalendarViewElement extends LitElement {
1367
1388
  this.minimapBufferCanvas = document.createElement("canvas");
1368
1389
  this.minimapBufferCtx = this.minimapBufferCanvas.getContext("2d");
1369
1390
 
1391
+ const self = this;
1392
+ const eventsState: EventsState = {
1393
+ get events() { return self.events; },
1394
+ get hoveredEventId() { return self.hoveredEventId; },
1395
+ isEventSelected: (event) => self.internal.isEventSelected(event),
1396
+ shouldRenderEventWithStripes: (event) => self.shouldRenderEventWithStripes(event),
1397
+ getStripePatternCanvas: () => self.getStripePatternCanvas(),
1398
+ };
1399
+ const heatmapState = {
1400
+ get events() { return self.heatmapEvents; },
1401
+ };
1402
+ this.eventsLayer = createEventsLayer(eventsState);
1403
+ this.layers = [
1404
+ createGridLayer(),
1405
+ createTimeseriesHeatmapLayer(heatmapState),
1406
+ this.eventsLayer,
1407
+ ];
1408
+
1370
1409
  this.weeks = this.internal.generateWeeks();
1371
1410
 
1372
1411
  // Restore zoom level from localStorage
@@ -1399,13 +1438,48 @@ export class CalendarViewElement extends LitElement {
1399
1438
  this.requestUpdate();
1400
1439
  }, 10000);
1401
1440
 
1441
+ // Extend range while stationary at the buffer boundary (no scroll events fire when idle)
1442
+ this.boundaryCheckInterval = setInterval(() => {
1443
+ this.checkAndExtendRange();
1444
+ }, 500);
1445
+
1402
1446
  // Try to restore saved scroll position, otherwise scroll to today
1403
1447
  this.loadScrollPosition();
1404
1448
 
1405
1449
  let previousFilter = this.filter;
1406
1450
 
1407
1451
  for await (const events of this.internal.events()) {
1408
- this.events = events;
1452
+ const timeseriesIds = this.loadTimeseriesSourceIds();
1453
+ let normalized = events;
1454
+ if (timeseriesIds.size > 0) {
1455
+ let needsClone = false;
1456
+ for (const event of events) {
1457
+ if (
1458
+ event.visualStyle !== "heatmap" &&
1459
+ event.sourceId &&
1460
+ timeseriesIds.has(event.sourceId)
1461
+ ) {
1462
+ needsClone = true;
1463
+ break;
1464
+ }
1465
+ }
1466
+ if (needsClone) {
1467
+ normalized = events.map((event) => {
1468
+ if (
1469
+ event.visualStyle === "heatmap" ||
1470
+ !event.sourceId ||
1471
+ !timeseriesIds.has(event.sourceId)
1472
+ ) {
1473
+ return event;
1474
+ }
1475
+ return { ...event, visualStyle: "heatmap" };
1476
+ });
1477
+ }
1478
+ }
1479
+ this.events = normalized;
1480
+ if (this.heatmapRange) {
1481
+ void this.refreshHeatmapEvents(true);
1482
+ }
1409
1483
 
1410
1484
  this.renderCanvas();
1411
1485
 
@@ -1548,6 +1622,101 @@ export class CalendarViewElement extends LitElement {
1548
1622
  }
1549
1623
  }
1550
1624
 
1625
+ resolveStyles(): Record<string, string> {
1626
+ const cs = getComputedStyle(this);
1627
+ const props = [
1628
+ "--bg-today",
1629
+ "--accent-current-time",
1630
+ "--grid-color",
1631
+ "--grid-color-strong",
1632
+ "--grid-color-hover",
1633
+ "--text-muted",
1634
+ "--text-primary",
1635
+ "--text-inverse",
1636
+ "--bg-primary",
1637
+ "--bg-elevated",
1638
+ ];
1639
+ const styles: Record<string, string> = {};
1640
+ for (const prop of props) {
1641
+ styles[prop] = cs.getPropertyValue(prop).trim();
1642
+ }
1643
+ return styles;
1644
+ }
1645
+
1646
+ private loadTimeseriesSourceIds(): Set<string> {
1647
+ const saved = localStorage.getItem("caldav-sources") || "";
1648
+ if (saved && saved === this.timeseriesSourceCacheKey) {
1649
+ return this.timeseriesSourceIds;
1650
+ }
1651
+ const ids = new Set<string>();
1652
+ if (saved) {
1653
+ try {
1654
+ const sources = JSON.parse(saved) as Array<{ id?: string; type?: string }>;
1655
+ for (const source of sources) {
1656
+ if (source?.type === "timeseries-json" && source.id) {
1657
+ ids.add(source.id);
1658
+ }
1659
+ }
1660
+ } catch {
1661
+ // Ignore malformed storage
1662
+ }
1663
+ }
1664
+ this.timeseriesSourceCacheKey = saved;
1665
+ this.timeseriesSourceIds = ids;
1666
+ return ids;
1667
+ }
1668
+
1669
+ private updateHeatmapRange(visibleWeeks: WeekInfo[]): void {
1670
+ if (visibleWeeks.length === 0) {
1671
+ this.heatmapRange = null;
1672
+ return;
1673
+ }
1674
+ const firstWeek = visibleWeeks[0]!;
1675
+ const lastWeek = visibleWeeks[visibleWeeks.length - 1]!;
1676
+ const startDay = firstWeek.days[0];
1677
+ const endDay = lastWeek.days[6];
1678
+ if (!startDay || !endDay) {
1679
+ this.heatmapRange = null;
1680
+ return;
1681
+ }
1682
+ const start = new Date(startDay);
1683
+ start.setHours(0, 0, 0, 0);
1684
+ const end = new Date(endDay);
1685
+ end.setHours(23, 59, 59, 999);
1686
+ this.heatmapRange = { start, end };
1687
+ }
1688
+
1689
+ private async refreshHeatmapEvents(force = false): Promise<void> {
1690
+ if (!this.heatmapRange) return;
1691
+ const { start, end } = this.heatmapRange;
1692
+ const timeseriesIds = this.loadTimeseriesSourceIds();
1693
+ const enabledKey = [...this.internal.enabledCalendars].join(",");
1694
+ const lockedKey = [...this.internal.lockedCalendars].join(",");
1695
+ const key = `${start.toISOString()}::${end.toISOString()}::${this.filter || ""}::${enabledKey}::${lockedKey}`;
1696
+ if (!force && key === this.heatmapQueryKey) return;
1697
+ this.heatmapQueryKey = key;
1698
+
1699
+ const token = ++this.heatmapQueryToken;
1700
+ try {
1701
+ let events: CalendarEvent[];
1702
+ if (this.internal.storage) {
1703
+ events = await this.internal.storage.queryEvents(start, end);
1704
+ } else {
1705
+ events = this.events;
1706
+ }
1707
+ if (token !== this.heatmapQueryToken) return;
1708
+
1709
+ const filtered = this.internal.filterEvents(events, this.filter);
1710
+ this.heatmapEvents = filtered.filter((event) => {
1711
+ if (event.visualStyle === "heatmap") return true;
1712
+ return event.sourceId ? timeseriesIds.has(event.sourceId) : false;
1713
+ });
1714
+ this.renderCanvas();
1715
+ } catch (error) {
1716
+ console.warn("Failed to refresh heatmap events:", error);
1717
+ }
1718
+ }
1719
+
1551
1720
  renderCanvas(): void {
1552
1721
  if (!this.ctx || !this.canvas || !this.scrollContainer) return;
1553
1722
 
@@ -1561,292 +1730,48 @@ export class CalendarViewElement extends LitElement {
1561
1730
  const scrollTop = this.scrollTop;
1562
1731
  const gridWidth = width - LEFT_GUTTER_WIDTH;
1563
1732
  const dayWidth = gridWidth / this._columnsPerRow;
1564
- const today = new Date();
1565
1733
 
1566
- // Find visible weeks
1567
1734
  const visibleWeeks = this.weeks.filter(
1568
1735
  (w) =>
1569
1736
  w.height > 0 &&
1570
1737
  w.yOffset + w.height > scrollTop &&
1571
1738
  w.yOffset < scrollTop + height,
1572
1739
  );
1740
+ this.updateHeatmapRange(visibleWeeks);
1741
+ this.refreshHeatmapEvents();
1742
+
1743
+ const lc: LayerContext = {
1744
+ ctx,
1745
+ width,
1746
+ height,
1747
+ scrollTop,
1748
+ dayWidth,
1749
+ dayHeight: this.dayHeight,
1750
+ leftGutterWidth: LEFT_GUTTER_WIDTH,
1751
+ columnsPerRow: this._columnsPerRow,
1752
+ rowsPerWeek: this.rowsPerWeek,
1753
+ visibleWeeks,
1754
+ allWeeks: this.weeks,
1755
+ fontFamily,
1756
+ styles: this.resolveStyles(),
1757
+ getDayVisualPosition: (dayIndex) => this.getDayVisualPosition(dayIndex),
1758
+ filter: this.filter,
1759
+ };
1573
1760
 
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();
1761
+ for (const layer of this.layers) {
1762
+ if (!layer.enabled) continue;
1763
+ ctx.save();
1764
+ layer.render(lc);
1765
+ ctx.restore();
1625
1766
  }
1626
1767
 
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
- }
1768
+ // Copy event rects from events layer for hit-testing
1769
+ if (this.eventsLayer) {
1770
+ this.eventRects = this.eventsLayer.eventRects;
1771
+ } else {
1772
+ this.eventRects = [];
1845
1773
  }
1846
1774
 
1847
- // Render events on canvas
1848
- this.renderEventsOnCanvas(ctx, scrollTop, height, dayWidth, visibleWeeks);
1849
-
1850
1775
  this.renderDateLabels();
1851
1776
 
1852
1777
  // Draw sticky weekday labels at top of viewport
@@ -1862,808 +1787,17 @@ export class CalendarViewElement extends LitElement {
1862
1787
  this.renderMinimap();
1863
1788
  }
1864
1789
 
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
- }
1790
+ toggleLayer(name: string): void {
1791
+ const layer = this.layers.find((l) => l.name === name);
1792
+ if (!layer) throw new Error(`Layer "${name}" not found`);
1793
+ layer.enabled = !layer.enabled;
1794
+ this.renderCanvas();
2663
1795
  }
2664
1796
 
1797
+
2665
1798
  onWheel = (e: WheelEvent): void => {
2666
1799
  if (this.hasAttribute("scroll-lock")) return;
1800
+ this.lastWheelTime = Date.now();
2667
1801
 
2668
1802
  if (this.scrollAnimationFrame) {
2669
1803
  this.cancelScrollAnimation();
@@ -3051,9 +2185,10 @@ export class CalendarViewElement extends LitElement {
3051
2185
  snappedEnd.setTime(snappedStart.getTime() + 15 * 60 * 1000);
3052
2186
  }
3053
2187
 
2188
+ const isZoomedOut = this.dayHeight < TIME_SCALE_DAY_HEIGHT;
3054
2189
  this.dispatchEvent(
3055
2190
  new CustomEvent("create-event", {
3056
- detail: { start: snappedStart, end: snappedEnd },
2191
+ detail: { start: snappedStart, end: snappedEnd, isAllDay: isZoomedOut },
3057
2192
  bubbles: true,
3058
2193
  }),
3059
2194
  );
@@ -3241,16 +2376,16 @@ export class CalendarViewElement extends LitElement {
3241
2376
  // Update cursor position for status bar
3242
2377
  this.cursorPosition = { x, y };
3243
2378
 
3244
- // Check for resize handles first
3245
- const resizeHandle = this.getResizeHandle(x, y);
2379
+ // Check for resize handles first — suppressed when alt key is active
2380
+ const resizeHandle = !e.altKey ? this.getResizeHandle(x, y) : null;
3246
2381
  if (resizeHandle && !this.isResizingEvent && !this.isDraggingEvent) {
3247
2382
  this.scrollContainer.style.cursor = "ns-resize";
3248
2383
  } else if (!this.isResizingEvent && !this.isDraggingEvent) {
3249
2384
  this.scrollContainer.style.cursor = "";
3250
2385
  }
3251
2386
 
3252
- // Check for event hover
3253
- const hoveredEvent = this.getEventAtPosition(x, y);
2387
+ // Check for event hover — suppressed when alt key is active
2388
+ const hoveredEvent = !e.altKey ? this.getEventAtPosition(x, y) : null;
3254
2389
  const newHoveredId = hoveredEvent ? hoveredEvent.id : null;
3255
2390
 
3256
2391
  if (newHoveredId !== this.hoveredEventId) {
@@ -3408,6 +2543,31 @@ export class CalendarViewElement extends LitElement {
3408
2543
  }
3409
2544
  };
3410
2545
 
2546
+ onKeyDown = (e: KeyboardEvent): void => {
2547
+ const focused = e.composedPath()[0] as HTMLElement;
2548
+ if (focused?.tagName === "INPUT" || focused?.tagName === "TEXTAREA" || focused?.isContentEditable) return;
2549
+ if (e.altKey && !this.altKeyActive) {
2550
+ this.altKeyActive = true;
2551
+ this.dispatchEvent(new CustomEvent("meta-key-change", { bubbles: true }));
2552
+ if (this.scrollContainer) {
2553
+ this.scrollContainer.style.cursor = "";
2554
+ }
2555
+ if (this.hoveredEventId !== null) {
2556
+ this.hoveredEventId = null;
2557
+ this.renderCanvas();
2558
+ }
2559
+ }
2560
+ };
2561
+
2562
+ onKeyUp = (e: KeyboardEvent): void => {
2563
+ const focused = e.composedPath()[0] as HTMLElement;
2564
+ if (focused?.tagName === "INPUT" || focused?.tagName === "TEXTAREA" || focused?.isContentEditable) return;
2565
+ if (!e.altKey && this.altKeyActive) {
2566
+ this.altKeyActive = false;
2567
+ this.dispatchEvent(new CustomEvent("meta-key-change", { bubbles: true }));
2568
+ }
2569
+ };
2570
+
3411
2571
  onPaste = async (e: ClipboardEvent): Promise<void> => {
3412
2572
  const files = e.clipboardData?.files;
3413
2573
  if (files && files.length > 0) {
@@ -3653,13 +2813,13 @@ export class CalendarViewElement extends LitElement {
3653
2813
  *
3654
2814
  * Selection box is 2D with different behavior based on zoom level:
3655
2815
  *
3656
- * ZOOMED OUT (dayHeight < 200):
2816
+ * ZOOMED OUT (dayHeight < TIME_SCALE_DAY_HEIGHT):
3657
2817
  * - X axis: days of the week (horizontal)
3658
2818
  * - Y axis: multiple weeks stacked (vertical)
3659
2819
  * - Creates time ranges per WEEK for selected day range
3660
2820
  * - Example: Wed-Thu across 3 weeks = 3 ranges (one per week)
3661
2821
  *
3662
- * ZOOMED IN (dayHeight >= 200, time scale visible):
2822
+ * ZOOMED IN (dayHeight >= TIME_SCALE_DAY_HEIGHT, time scale visible):
3663
2823
  * - X axis: days of the week (horizontal)
3664
2824
  * - Y axis: time within days (vertical, with weeks)
3665
2825
  * - Creates time ranges per DAY for selected time range
@@ -3705,7 +2865,7 @@ export class CalendarViewElement extends LitElement {
3705
2865
  if (intersectingWeeks.length === 0) return;
3706
2866
 
3707
2867
  const timeRanges: Array<{ start: Date; end: Date }> = [];
3708
- const isZoomedIn = this.dayHeight >= 200;
2868
+ const isZoomedIn = this.dayHeight >= TIME_SCALE_DAY_HEIGHT;
3709
2869
 
3710
2870
  if (isZoomedIn) {
3711
2871
  // ZOOMED IN: Create time ranges per DAY (same time across multiple weeks)
@@ -3794,6 +2954,10 @@ export class CalendarViewElement extends LitElement {
3794
2954
  for (const event of this.events) {
3795
2955
  // Skip all-day events
3796
2956
  if (event.isAllDay) continue;
2957
+ if (event.visualStyle === "heatmap") continue;
2958
+
2959
+ // Only select events from the active calendar
2960
+ if (this.activeCalendarId && event.calendarId !== this.activeCalendarId && event.sourceId !== this.activeCalendarId) continue;
3797
2961
 
3798
2962
  const eventStartTime = event.start.getTime();
3799
2963
  const eventEndTime = event.end.getTime();
@@ -3885,9 +3049,17 @@ export class CalendarViewElement extends LitElement {
3885
3049
  const scrollTop = this.scrollTop;
3886
3050
  const scrollBottom = scrollTop + this.viewportHeight;
3887
3051
 
3052
+ const nearTop = scrollTop < bufferHeight;
3053
+ const nearBottom = scrollBottom > this.totalHeight - bufferHeight;
3054
+ if (!nearTop && !nearBottom) return;
3055
+
3888
3056
  // Check if near top (extending to past)
3889
- if (scrollTop < bufferHeight) {
3057
+ if (nearTop) {
3890
3058
  this.isExtendingRange = true;
3059
+ // Capture before extension: only compensate for wheel/touch scroll.
3060
+ // For scrollbar drags, skip compensation so the thumb stays where the user
3061
+ // put it and they see the newly loaded historical content — no treadmill.
3062
+ const isWheelScroll = Date.now() - this.lastWheelTime < 200;
3891
3063
  const newWeeks = this.internal.extendRange("past");
3892
3064
 
3893
3065
  // Prepend new weeks
@@ -3906,11 +3078,13 @@ export class CalendarViewElement extends LitElement {
3906
3078
  // Must update DOM first so scroll container has correct height
3907
3079
  this.requestUpdate();
3908
3080
 
3909
- // Adjust scroll position to maintain visual position (after DOM update)
3910
3081
  requestAnimationFrame(() => {
3911
- this._scrollTop += addedHeight;
3912
- if (this.scrollContainer) {
3913
- this.scrollContainer.scrollTop = this._scrollTop;
3082
+ if (isWheelScroll) {
3083
+ // Maintain visual position so content doesn't jump during wheel scroll
3084
+ this._scrollTop += addedHeight;
3085
+ if (this.scrollContainer) {
3086
+ this.scrollContainer.scrollTop = this._scrollTop;
3087
+ }
3914
3088
  }
3915
3089
 
3916
3090
  this.renderCanvas();
@@ -3921,7 +3095,7 @@ export class CalendarViewElement extends LitElement {
3921
3095
  }
3922
3096
 
3923
3097
  // Check if near bottom (extending to future)
3924
- if (scrollBottom > this.totalHeight - bufferHeight) {
3098
+ if (nearBottom) {
3925
3099
  this.isExtendingRange = true;
3926
3100
  const newWeeks = this.internal.extendRange("future");
3927
3101
 
@@ -4194,6 +3368,7 @@ export class CalendarViewElement extends LitElement {
4194
3368
  fill: accent.replace("rgb", "rgba").replace(")", ", 0.3)"),
4195
3369
  stroke: accent.replace("rgb", "rgba").replace(")", ", 0.8)"),
4196
3370
  text: "white",
3371
+ dashed: false,
4197
3372
  },
4198
3373
  useStartEdge,
4199
3374
  );
@@ -4206,6 +3381,7 @@ export class CalendarViewElement extends LitElement {
4206
3381
  fill: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.5)`,
4207
3382
  stroke: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.5)`,
4208
3383
  text: "rgba(255, 255, 255, 0.5)",
3384
+ dashed: false,
4209
3385
  },
4210
3386
  useStartEdge,
4211
3387
  );
@@ -4250,8 +3426,13 @@ export class CalendarViewElement extends LitElement {
4250
3426
  later = startDate < endDate ? endDate : startDate;
4251
3427
  }
4252
3428
 
4253
- earlier.setMinutes(Math.round(earlier.getMinutes() / 15) * 15, 0, 0);
4254
- later.setMinutes(Math.round(later.getMinutes() / 15) * 15, 0, 0);
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);
3435
+ }
4255
3436
 
4256
3437
  // Use active calendar color if available, otherwise fall back to accent-primary
4257
3438
  let fill: string;
@@ -4279,7 +3460,7 @@ export class CalendarViewElement extends LitElement {
4279
3460
  renderVirtualEvent(
4280
3461
  start: Date,
4281
3462
  end: Date,
4282
- color: { fill: string; stroke: string; text: string },
3463
+ color: { fill: string; stroke: string; text: string; dashed?: boolean },
4283
3464
  useStartEdge = true,
4284
3465
  ): void {
4285
3466
  if (!this.overlayCanvas || !this.overlayCtx || !this.scrollContainer)
@@ -4329,7 +3510,7 @@ export class CalendarViewElement extends LitElement {
4329
3510
  ctx.fill();
4330
3511
  ctx.strokeStyle = color.stroke;
4331
3512
  ctx.lineWidth = 1;
4332
- ctx.setLineDash([6, 3]);
3513
+ if (color.dashed !== false) ctx.setLineDash([6, 3]);
4333
3514
  ctx.stroke();
4334
3515
  ctx.setLineDash([]);
4335
3516
  };
@@ -4813,7 +3994,7 @@ export class CalendarViewElement extends LitElement {
4813
3994
  let hour = 0;
4814
3995
  let minute = 0;
4815
3996
 
4816
- if (this.dayHeight >= 200) {
3997
+ if (this.dayHeight >= TIME_SCALE_DAY_HEIGHT) {
4817
3998
  const offsetInRow = y - (week.yOffset + rowInWeek * this.dayHeight);
4818
3999
  const minutes = Math.floor((offsetInRow / this.dayHeight) * 24 * 60);
4819
4000
  hour = Math.floor(minutes / 60);
@@ -4897,6 +4078,7 @@ export class CalendarViewElement extends LitElement {
4897
4078
  formattedCursorDate: cursorTime
4898
4079
  ? this.formatCompactDate(cursorTime.date)
4899
4080
  : "",
4081
+ altKeyActive: this.altKeyActive,
4900
4082
  };
4901
4083
  }
4902
4084
 
@@ -5635,18 +4817,3 @@ export class CalendarViewElement extends LitElement {
5635
4817
  `;
5636
4818
  }
5637
4819
  }
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
- }