@luckydye/calendar 1.2.3 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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`
@@ -55,31 +57,24 @@ export class CalendarViewElement extends LitElement {
55
57
  align-items: center;
56
58
  justify-content: space-between;
57
59
  padding: 8px 10px;
58
- background: var(--bg-secondary, rgba(36, 36, 38, 0.5));
59
- border: 1px solid var(--grid-color, rgba(255, 255, 255, 0.1));
60
+ background: var(--bg-primary, rgb(30, 30, 30));
60
61
  flex-shrink: 0;
61
62
  gap: 16px;
62
63
  position: absolute;
63
- bottom: .25rem;
64
- left: .5rem;
65
- right: 0.5rem;
64
+ bottom: 0;
65
+ left: 0;
66
+ right: 0;
66
67
  z-index: 100;
67
- border-radius: var(--border-radius-lg, 8px);
68
- overflow: hidden;
69
-
70
- --backdrop-padding: 30px;
71
- --backdrop-size: 42px;
72
68
  }
73
69
 
74
70
  .toolbar::before {
75
71
  content: '';
76
- width: 100%;
77
72
  position: absolute;
78
- top: calc(var(--backdrop-padding) * -1);
79
73
  left: 0;
80
- bottom: calc(var(--backdrop-padding) * -1);
81
- backdrop-filter: blur(var(--backdrop-size));
82
- z-index: -1;
74
+ right: 0;
75
+ bottom: 100%;
76
+ height: 48px;
77
+ background: linear-gradient(to bottom, transparent, var(--bg-primary, rgb(30, 30, 30)));
83
78
  pointer-events: none;
84
79
  }
85
80
 
@@ -460,13 +455,14 @@ export class CalendarViewElement extends LitElement {
460
455
  flex-direction: column;
461
456
  min-width: 0;
462
457
  max-height: 550px;
458
+ border-radius: var(--border-radius-lg, 8px);
459
+ overflow: hidden;
463
460
  }
464
461
 
465
462
  .event-detail-footer {
466
463
  border-top: 1px solid var(--grid-color, rgba(255, 255, 255, 0.1));
467
464
  padding: 12px 16px;
468
465
  background: var(--bg-elevated, rgba(20, 20, 20, 0.95));
469
- border-radius: 0 0 var(--border-radius-lg, 8px) 0;
470
466
  }
471
467
 
472
468
  .invite-response-buttons {
@@ -860,6 +856,14 @@ export class CalendarViewElement extends LitElement {
860
856
  minimapBufferCanvas: HTMLCanvasElement | null = null;
861
857
  minimapBufferCtx: CanvasRenderingContext2D | null = null;
862
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;
863
867
  scrollContainer: HTMLElement | null = null;
864
868
  scrollContent: HTMLElement | null = null;
865
869
  resizeObserver: ResizeObserver | null = null;
@@ -881,9 +885,12 @@ export class CalendarViewElement extends LitElement {
881
885
  cursorPosition: { x: number; y: number } | null = null; // Mouse position in calendar coordinates
882
886
  animationFrame: number | null = null;
883
887
  isDraggingMinimap = false;
888
+ altKeyActive = false; // Tracks if alt/meta key is held (bypasses event interaction)
884
889
  isFiltered = false; // Tracks if filter is currently active
885
890
  timeUpdateInterval: ReturnType<typeof setInterval> | null = null;
886
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
887
894
  isCreatingEvent = false;
888
895
  eventCreationStart: { x: number; y: number } | null = null;
889
896
  eventCreationEnd: { x: number; y: number } | null = null;
@@ -998,6 +1005,7 @@ export class CalendarViewElement extends LitElement {
998
1005
  currentTheme: ThemeName = loadThemePreference();
999
1006
 
1000
1007
  activeCalendarColor: string | null = null;
1008
+ activeCalendarId: string | null = null;
1001
1009
 
1002
1010
  get filter() {
1003
1011
  return this.internal.getFilter();
@@ -1308,6 +1316,8 @@ export class CalendarViewElement extends LitElement {
1308
1316
  window.addEventListener("mousemove", this.onMouseMove);
1309
1317
  window.addEventListener("mouseup", this.onMouseUp);
1310
1318
  window.addEventListener("paste", this.onPaste);
1319
+ window.addEventListener("keydown", this.onKeyDown);
1320
+ window.addEventListener("keyup", this.onKeyUp);
1311
1321
  }
1312
1322
 
1313
1323
  disconnectedCallback() {
@@ -1316,6 +1326,8 @@ export class CalendarViewElement extends LitElement {
1316
1326
  window.removeEventListener("mousemove", this.onMouseMove);
1317
1327
  window.removeEventListener("mouseup", this.onMouseUp);
1318
1328
  window.removeEventListener("paste", this.onPaste);
1329
+ window.removeEventListener("keydown", this.onKeyDown);
1330
+ window.removeEventListener("keyup", this.onKeyUp);
1319
1331
 
1320
1332
  if (this.scrollContainer) {
1321
1333
  this.scrollContainer.removeEventListener(
@@ -1332,6 +1344,9 @@ export class CalendarViewElement extends LitElement {
1332
1344
  if (this.timeUpdateInterval) {
1333
1345
  clearInterval(this.timeUpdateInterval);
1334
1346
  }
1347
+ if (this.boundaryCheckInterval) {
1348
+ clearInterval(this.boundaryCheckInterval);
1349
+ }
1335
1350
  }
1336
1351
 
1337
1352
  onScrollContainerMouseLeave = (): void => {
@@ -1373,6 +1388,24 @@ export class CalendarViewElement extends LitElement {
1373
1388
  this.minimapBufferCanvas = document.createElement("canvas");
1374
1389
  this.minimapBufferCtx = this.minimapBufferCanvas.getContext("2d");
1375
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
+
1376
1409
  this.weeks = this.internal.generateWeeks();
1377
1410
 
1378
1411
  // Restore zoom level from localStorage
@@ -1405,13 +1438,48 @@ export class CalendarViewElement extends LitElement {
1405
1438
  this.requestUpdate();
1406
1439
  }, 10000);
1407
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
+
1408
1446
  // Try to restore saved scroll position, otherwise scroll to today
1409
1447
  this.loadScrollPosition();
1410
1448
 
1411
1449
  let previousFilter = this.filter;
1412
1450
 
1413
1451
  for await (const events of this.internal.events()) {
1414
- 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
+ }
1415
1483
 
1416
1484
  this.renderCanvas();
1417
1485
 
@@ -1554,6 +1622,101 @@ export class CalendarViewElement extends LitElement {
1554
1622
  }
1555
1623
  }
1556
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
+
1557
1720
  renderCanvas(): void {
1558
1721
  if (!this.ctx || !this.canvas || !this.scrollContainer) return;
1559
1722
 
@@ -1567,292 +1730,48 @@ export class CalendarViewElement extends LitElement {
1567
1730
  const scrollTop = this.scrollTop;
1568
1731
  const gridWidth = width - LEFT_GUTTER_WIDTH;
1569
1732
  const dayWidth = gridWidth / this._columnsPerRow;
1570
- const today = new Date();
1571
1733
 
1572
- // Find visible weeks
1573
1734
  const visibleWeeks = this.weeks.filter(
1574
1735
  (w) =>
1575
1736
  w.height > 0 &&
1576
1737
  w.yOffset + w.height > scrollTop &&
1577
1738
  w.yOffset < scrollTop + height,
1578
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
+ };
1579
1760
 
1580
- // Draw today highlight and current time indicator
1581
- const showTimeScale = this.dayHeight >= 300;
1582
- for (const week of visibleWeeks) {
1583
- const todayIndex = week.days.findIndex((d) =>
1584
- CalendarInternal.isSameDay(d, today),
1585
- );
1586
- if (todayIndex >= 0) {
1587
- const { row, col } = this.getDayVisualPosition(todayIndex);
1588
- const x = LEFT_GUTTER_WIDTH + col * dayWidth;
1589
- const dayY = week.yOffset + row * this.dayHeight - scrollTop;
1590
- const bgToday =
1591
- getComputedStyle(this).getPropertyValue("--bg-today").trim() ||
1592
- "rgba(255, 255, 255, 0.05)";
1593
- ctx.fillStyle = bgToday;
1594
- ctx.fillRect(x, dayY, dayWidth, this.dayHeight);
1595
-
1596
- if (showTimeScale) {
1597
- // Draw current time indicator line (zoomed in)
1598
- const now = new Date();
1599
- const currentMinutes = now.getHours() * 60 + now.getMinutes();
1600
- const timeY = dayY + (currentMinutes / 1440) * this.dayHeight;
1601
- if (timeY >= 0 && timeY <= height) {
1602
- const accentTime =
1603
- getComputedStyle(this)
1604
- .getPropertyValue("--accent-current-time")
1605
- .trim() || "rgba(255, 0, 0, 0.8)";
1606
- ctx.strokeStyle = accentTime;
1607
- ctx.lineWidth = 1;
1608
- ctx.beginPath();
1609
- ctx.moveTo(x, timeY);
1610
- ctx.lineTo(x + dayWidth, timeY);
1611
- ctx.stroke();
1612
- ctx.lineWidth = 1;
1613
- }
1614
- }
1615
- }
1616
- }
1617
-
1618
- // Draw grid lines
1619
- ctx.strokeStyle =
1620
- getComputedStyle(this).getPropertyValue("--grid-color").trim() ||
1621
- "rgba(255, 255, 255, 0.1)";
1622
- ctx.lineWidth = 1;
1623
-
1624
- // Vertical lines (day separators) - draw for each column
1625
- for (let i = 1; i <= this._columnsPerRow; i++) {
1626
- const x = LEFT_GUTTER_WIDTH + i * dayWidth;
1627
- ctx.beginPath();
1628
- ctx.moveTo(x, 0);
1629
- ctx.lineTo(x, height);
1630
- ctx.stroke();
1761
+ for (const layer of this.layers) {
1762
+ if (!layer.enabled) continue;
1763
+ ctx.save();
1764
+ layer.render(lc);
1765
+ ctx.restore();
1631
1766
  }
1632
1767
 
1633
- // Horizontal lines (week separators) and left gutter content
1634
- for (let i = 0; i < visibleWeeks.length; i++) {
1635
- const week = visibleWeeks[i];
1636
- if (!week) continue;
1637
- const y = week.yOffset - scrollTop;
1638
-
1639
- // Draw gap indicator if there are hidden weeks before this one
1640
- if (this.filter && i > 0) {
1641
- const prevWeek = visibleWeeks[i - 1];
1642
- if (!prevWeek) continue;
1643
- const prevWeekIndex = this.weeks.indexOf(prevWeek);
1644
- const currentWeekIndex = this.weeks.indexOf(week);
1645
- const hiddenWeeks = currentWeekIndex - prevWeekIndex - 1;
1646
-
1647
- if (hiddenWeeks > 0) {
1648
- // Draw gap indicator at the top of the current week
1649
- const gapY = y;
1650
-
1651
- // Draw dashed line
1652
- const gridColorStrong =
1653
- getComputedStyle(this)
1654
- .getPropertyValue("--grid-color-strong")
1655
- .trim() || "rgba(255, 255, 255, 0.3)";
1656
- ctx.strokeStyle = gridColorStrong;
1657
- ctx.lineWidth = 1;
1658
- ctx.setLineDash([4, 4]);
1659
- ctx.beginPath();
1660
- ctx.moveTo(LEFT_GUTTER_WIDTH, gapY);
1661
- ctx.lineTo(width, gapY);
1662
- ctx.stroke();
1663
- ctx.setLineDash([]);
1664
-
1665
- // Draw ellipsis in the center
1666
- const textMuted =
1667
- getComputedStyle(this).getPropertyValue("--text-muted").trim() ||
1668
- "rgba(255, 255, 255, 0.4)";
1669
- ctx.fillStyle = textMuted;
1670
- ctx.textAlign = "center";
1671
- const ellipsisText = `⋯ ${hiddenWeeks} week${
1672
- hiddenWeeks > 1 ? "s" : ""
1673
- }`;
1674
-
1675
- // Draw background pill for the ellipsis
1676
- const textWidth = ctx.measureText(ellipsisText).width;
1677
- const pillPadding = 8;
1678
- const pillX =
1679
- (LEFT_GUTTER_WIDTH + width) / 2 - textWidth / 2 - pillPadding;
1680
- const pillY = gapY - 8;
1681
- const pillWidth = textWidth + pillPadding * 2;
1682
- const pillHeight = 16;
1683
-
1684
- const bgPrimary =
1685
- getComputedStyle(this).getPropertyValue("--bg-primary").trim() ||
1686
- "rgba(30, 30, 30, 0.9)";
1687
- ctx.fillStyle = bgPrimary;
1688
- ctx.beginPath();
1689
- ctx.roundRect(pillX, pillY, pillWidth, pillHeight, 8);
1690
- ctx.fill();
1691
-
1692
- ctx.fillStyle = textMuted;
1693
- ctx.fillText(ellipsisText, (LEFT_GUTTER_WIDTH + width) / 2, gapY + 4);
1694
- }
1695
- }
1696
-
1697
- // Week separator line
1698
- ctx.strokeStyle =
1699
- getComputedStyle(this).getPropertyValue("--grid-color").trim() ||
1700
- "rgba(255, 255, 255, 0.1)";
1701
- ctx.beginPath();
1702
- ctx.moveTo(LEFT_GUTTER_WIDTH, y);
1703
- ctx.lineTo(width, y);
1704
- ctx.stroke();
1705
-
1706
- // Draw horizontal lines between visual rows within this week
1707
- for (let row = 1; row < this.rowsPerWeek; row++) {
1708
- const rowY = y + row * this.dayHeight;
1709
- if (rowY >= 0 && rowY <= height) {
1710
- ctx.beginPath();
1711
- ctx.moveTo(LEFT_GUTTER_WIDTH, rowY);
1712
- ctx.lineTo(width, rowY);
1713
- ctx.stroke();
1714
- }
1715
- }
1716
-
1717
- // Left gutter: week number and time scale
1718
- const hourLabelOpacity = Math.max(
1719
- 0,
1720
- Math.min(1, (this.dayHeight - 300) / 300),
1721
- );
1722
-
1723
- ctx.font = `500 11px ${fontFamily}`;
1724
- ctx.textBaseline = "bottom";
1725
- ctx.textAlign = "right";
1726
-
1727
- // Draw hourly lines and labels for each visual row
1728
- const gridColor =
1729
- getComputedStyle(this).getPropertyValue("--grid-color").trim() ||
1730
- "rgba(255, 255, 255, 0.1)";
1731
- const textMuted =
1732
- getComputedStyle(this).getPropertyValue("--text-muted").trim() ||
1733
- "rgba(255, 255, 255, 0.4)";
1734
- ctx.strokeStyle = gridColor.replace(
1735
- /[\d.]+\)$/,
1736
- `${0.05 * hourLabelOpacity})`,
1737
- );
1738
-
1739
- // Calculate opacity for hour labels based on zoom level
1740
- // Fade in as we zoom in from 200px to 400px
1741
- ctx.fillStyle = textMuted.replace(
1742
- /[\d.]+\)$/,
1743
- `${0.4 * hourLabelOpacity})`,
1744
- );
1745
-
1746
- for (let row = 0; row < this.rowsPerWeek; row++) {
1747
- const rowY = y + row * this.dayHeight;
1748
- for (let hour = 0; hour < 24; hour++) {
1749
- const hourY = rowY + (hour / 24) * this.dayHeight;
1750
- if (hourY >= 0 && hourY <= height) {
1751
- // Hour line
1752
- ctx.beginPath();
1753
- ctx.moveTo(LEFT_GUTTER_WIDTH, hourY);
1754
- ctx.lineTo(width, hourY);
1755
- ctx.stroke();
1756
-
1757
- // Hour label (only draw if opacity is significant)
1758
- if (hourLabelOpacity > 0.1) {
1759
- const label = `${hour.toString().padStart(2, "0")}:00`;
1760
- ctx.fillText(label, 48, hourY + 4);
1761
- }
1762
- }
1763
- }
1764
- }
1765
-
1766
- // Draw current time indicator in left gutter (only when timescale is visible and today is in this week)
1767
- if (hourLabelOpacity > 0.1) {
1768
- const today = new Date();
1769
- const todayIndex = week.days.findIndex((d) =>
1770
- CalendarInternal.isSameDay(d, today),
1771
- );
1772
-
1773
- if (todayIndex >= 0) {
1774
- const { row } = this.getDayVisualPosition(todayIndex);
1775
- const currentMinutes = today.getHours() * 60 + today.getMinutes();
1776
- const timeY =
1777
- y + row * this.dayHeight + (currentMinutes / 1440) * this.dayHeight;
1778
-
1779
- if (timeY >= 0 && timeY <= height) {
1780
- const hours = today.getHours().toString().padStart(2, "0");
1781
- const minutes = today.getMinutes().toString().padStart(2, "0");
1782
- const timeText = `${hours}:${minutes}`;
1783
-
1784
- ctx.save();
1785
- ctx.textAlign = "right";
1786
- ctx.textBaseline = "middle";
1787
-
1788
- const textWidth = ctx.measureText(timeText).width;
1789
- const bgPaddingX = 6;
1790
- const bgPaddingY = 3;
1791
- const textX = 48;
1792
- const textY = timeY;
1793
-
1794
- // Draw background pill
1795
- const bgElevated =
1796
- getComputedStyle(this).getPropertyValue("--bg-elevated").trim() ||
1797
- "rgba(0, 0, 0, 0.7)";
1798
- ctx.fillStyle = bgElevated;
1799
- ctx.beginPath();
1800
- ctx.roundRect(
1801
- textX - textWidth - bgPaddingX,
1802
- textY - 8,
1803
- textWidth + bgPaddingX * 2,
1804
- 16,
1805
- 4,
1806
- );
1807
- ctx.fill();
1808
-
1809
- // Draw current time text in white
1810
- ctx.fillStyle =
1811
- getComputedStyle(this)
1812
- .getPropertyValue("--text-primary")
1813
- .trim() || "rgba(255, 255, 255, 1)";
1814
- ctx.fillText(timeText, textX, textY);
1815
- ctx.restore();
1816
- }
1817
- }
1818
- }
1819
-
1820
- // Draw week number - sticky within visible portion of week
1821
- // Fade out week numbers as we zoom in from 150px to 200px
1822
- const weekNumberOpacity = Math.max(
1823
- 0,
1824
- Math.min(1, 1 - (this.dayHeight - 300) / 50),
1825
- );
1826
- const textMutedForWeek =
1827
- getComputedStyle(this).getPropertyValue("--text-muted").trim() ||
1828
- "rgba(255, 255, 255, 0.4)";
1829
- ctx.fillStyle = textMutedForWeek.replace(
1830
- /[\d.]+\)$/,
1831
- `${0.4 * weekNumberOpacity})`,
1832
- );
1833
- ctx.textAlign = "center";
1834
- const label = `W${week.weekNumber}`;
1835
- const weekTop = y;
1836
- const weekBottom = y + week.height;
1837
- // Clamp label to be visible: at least 14px from top of viewport,
1838
- // but within the week's bounds
1839
- const labelY = Math.max(
1840
- 14,
1841
- Math.min(weekTop + week.height / 2 + 4, weekBottom - 4),
1842
- );
1843
- // Only draw if the label position is within the week's visible area and viewport and opacity is significant
1844
- if (
1845
- labelY >= Math.max(0, weekTop + 4) &&
1846
- labelY <= Math.min(height, weekBottom) &&
1847
- weekNumberOpacity > 0.1
1848
- ) {
1849
- ctx.fillText(label, 30, labelY);
1850
- }
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 = [];
1851
1773
  }
1852
1774
 
1853
- // Render events on canvas
1854
- this.renderEventsOnCanvas(ctx, scrollTop, height, dayWidth, visibleWeeks);
1855
-
1856
1775
  this.renderDateLabels();
1857
1776
 
1858
1777
  // Draw sticky weekday labels at top of viewport
@@ -1868,808 +1787,17 @@ export class CalendarViewElement extends LitElement {
1868
1787
  this.renderMinimap();
1869
1788
  }
1870
1789
 
1871
- renderEventsOnCanvas(
1872
- ctx: CanvasRenderingContext2D,
1873
- scrollTop: number,
1874
- height: number,
1875
- dayWidth: number,
1876
- visibleWeeks: WeekInfo[],
1877
- ): void {
1878
- const events = this.events;
1879
- const fontFamily = getComputedStyle(this).fontFamily;
1880
- const viewportBottom = scrollTop + height;
1881
- const showTimeScale = this.dayHeight >= 300;
1882
-
1883
- // Clear event rects for new render
1884
- this.eventRects = [];
1885
-
1886
- if (visibleWeeks.length === 0) return;
1887
-
1888
- // Compute visible date range
1889
- const firstVisibleWeek = visibleWeeks[0]!;
1890
- const lastVisibleWeek = visibleWeeks[visibleWeeks.length - 1]!;
1891
- const firstVisibleDay = firstVisibleWeek.days[0]!;
1892
- const lastVisibleDay = lastVisibleWeek.days[6]!;
1893
- const visibleStartTime = firstVisibleDay.getTime();
1894
- const visibleEndTime = lastVisibleDay.getTime() + 86400000 - 1;
1895
-
1896
- // Track occupied row slots per day-column for stacking
1897
- const dayOccupiedRows = new Map<string, Set<number>>();
1898
- const eventRowIndex = new Map<string, number>();
1899
-
1900
- const segments: EventSegment[] = [];
1901
-
1902
- // Build segments - separate all-day and timed events
1903
- const allDaySegments: EventSegment[] = [];
1904
- const timedSegments: EventSegment[] = [];
1905
-
1906
- for (const event of events) {
1907
- const eventStartTime = event.start.getTime();
1908
- const eventEndTime = event.end.getTime();
1909
-
1910
- if (eventEndTime < visibleStartTime || eventStartTime > visibleEndTime)
1911
- continue;
1912
-
1913
- // Only iterate through visible weeks
1914
- const eventWeeks: { weekIndex: number; week: WeekInfo }[] = [];
1915
-
1916
- for (const week of visibleWeeks) {
1917
- const weekStartDay = week.days[0];
1918
- const weekEndDay = week.days[6];
1919
- if (!weekStartDay || !weekEndDay) continue;
1920
-
1921
- const weekStart = weekStartDay.getTime();
1922
- const weekEnd = weekEndDay.getTime() + 86399999;
1923
-
1924
- if (eventEndTime >= weekStart && eventStartTime <= weekEnd) {
1925
- const weekIndex = this.weeks.indexOf(week);
1926
- eventWeeks.push({ weekIndex, week });
1927
- }
1928
- }
1929
-
1930
- const totalWeeks = eventWeeks.length;
1931
- const isAllDay = event.isAllDay === true;
1932
-
1933
- for (let i = 0; i < eventWeeks.length; i++) {
1934
- const { weekIndex, week } = eventWeeks[i]!;
1935
-
1936
- const isStart = i === 0;
1937
- const isEnd = i === eventWeeks.length - 1;
1938
-
1939
- let startDayIndex = 0;
1940
- let endDayIndex = 6;
1941
-
1942
- if (isStart) {
1943
- startDayIndex = getDayIndexInWeek(week, event.start);
1944
- }
1945
- if (isEnd) {
1946
- // For all-day events, the end date is exclusive (e.g., Monday event ends Tuesday 00:00)
1947
- // Subtract 1ms to get the actual last day
1948
- const effectiveEnd = isAllDay
1949
- ? new Date(event.end.getTime() - 1)
1950
- : event.end;
1951
- endDayIndex = getDayIndexInWeek(week, effectiveEnd);
1952
- }
1953
-
1954
- const segment = {
1955
- event,
1956
- weekIndex,
1957
- week,
1958
- startDayIndex,
1959
- endDayIndex,
1960
- isStart,
1961
- isEnd,
1962
- totalWeeks,
1963
- };
1964
-
1965
- if (isAllDay) {
1966
- allDaySegments.push(segment);
1967
- } else {
1968
- timedSegments.push(segment);
1969
- }
1970
- }
1971
- }
1972
-
1973
- // In timescale mode, split multi-day timed segments into per-day sub-segments
1974
- // so each day renders its own block with correct time positions (like renderVirtualEvent).
1975
- const renderedTimedSegments = showTimeScale
1976
- ? timedSegments.flatMap((seg) => {
1977
- if (seg.startDayIndex === seg.endDayIndex) return [seg];
1978
- const result = [];
1979
- for (let d = seg.startDayIndex; d <= seg.endDayIndex; d++) {
1980
- result.push({
1981
- ...seg,
1982
- startDayIndex: d,
1983
- endDayIndex: d,
1984
- isStart: d === seg.startDayIndex && seg.isStart,
1985
- isEnd: d === seg.endDayIndex && seg.isEnd,
1986
- totalWeeks: 1,
1987
- });
1988
- }
1989
- return result;
1990
- })
1991
- : timedSegments;
1992
-
1993
- // Combine segments with all-day events first (so they get top rows)
1994
- segments.push(...allDaySegments, ...renderedTimedSegments);
1995
-
1996
- // Calculate columns for overlapping events in time scale mode
1997
- interface EventLayout {
1998
- segment: EventSegment;
1999
- yStart: number;
2000
- yEnd: number;
2001
- column: number;
2002
- totalColumns: number;
2003
- }
2004
- const eventLayouts: EventLayout[] = [];
2005
-
2006
- // Group segments by week and day for overlap detection
2007
- const segmentsByWeekDay = new Map<string, EventSegment[]>();
2008
- for (const segment of segments) {
2009
- const allDay = segment.event.isAllDay === true;
2010
-
2011
- // Only do column layout for time-scaled non-all-day events
2012
- if (showTimeScale && !allDay) {
2013
- // For multi-day events in time scale, each day gets its own column calculation
2014
- for (
2015
- let dayIdx = segment.startDayIndex;
2016
- dayIdx <= segment.endDayIndex;
2017
- dayIdx++
2018
- ) {
2019
- const key = `${segment.weekIndex}-${dayIdx}`;
2020
- if (!segmentsByWeekDay.has(key)) {
2021
- segmentsByWeekDay.set(key, []);
2022
- }
2023
- segmentsByWeekDay.get(key)!.push(segment);
2024
- }
2025
- }
2026
- }
2027
-
2028
- // Calculate column assignments for each day's overlapping events
2029
- const segmentColumns = new Map<
2030
- string,
2031
- { column: number; totalColumns: number }
2032
- >();
2033
-
2034
- for (const [dayKey, daySegments] of segmentsByWeekDay) {
2035
- const [weekIndexStr, dayIndexStr] = dayKey.split("-");
2036
- const dayIndex = parseInt(dayIndexStr!);
2037
- const weekIndex = parseInt(weekIndexStr!);
2038
-
2039
- // Calculate time ranges for each segment in this day
2040
- interface SegmentTime {
2041
- segment: EventSegment;
2042
- startMinutes: number;
2043
- endMinutes: number;
2044
- }
2045
- const segmentTimes: SegmentTime[] = [];
2046
-
2047
- for (const seg of daySegments) {
2048
- const week = this.weeks[weekIndex];
2049
- if (!week) continue;
2050
-
2051
- const dayStartTime = new Date(week.days[dayIndex]!).setHours(
2052
- 0,
2053
- 0,
2054
- 0,
2055
- 0,
2056
- );
2057
- const dayEndTime = new Date(week.days[dayIndex]!).setHours(
2058
- 23,
2059
- 59,
2060
- 59,
2061
- 999,
2062
- );
2063
- const eventStartTime = seg.event.start.getTime();
2064
- const eventEndTime = seg.event.end.getTime() - 60000; // Subtract 1 minute for rendering
2065
-
2066
- const effectiveStartTime = Math.max(eventStartTime, dayStartTime);
2067
- const effectiveEndTime = Math.min(eventEndTime, dayEndTime);
2068
-
2069
- const effectiveStart = new Date(effectiveStartTime);
2070
- const effectiveEnd = new Date(effectiveEndTime);
2071
-
2072
- const startMinutes =
2073
- effectiveStart.getHours() * 60 + effectiveStart.getMinutes();
2074
- const endMinutes =
2075
- effectiveEnd.getHours() * 60 + effectiveEnd.getMinutes();
2076
-
2077
- segmentTimes.push({ segment: seg, startMinutes, endMinutes });
2078
- }
2079
-
2080
- // Sort by start time, then by duration (longer first)
2081
- segmentTimes.sort((a, b) => {
2082
- if (a.startMinutes !== b.startMinutes)
2083
- return a.startMinutes - b.startMinutes;
2084
- return b.endMinutes - b.startMinutes - (a.endMinutes - a.startMinutes);
2085
- });
2086
-
2087
- // Assign columns using a greedy algorithm
2088
- const columns: { endMinutes: number }[] = [];
2089
-
2090
- for (const st of segmentTimes) {
2091
- // Find the first column where this event fits
2092
- let columnIndex = 0;
2093
- for (; columnIndex < columns.length; columnIndex++) {
2094
- if (columns[columnIndex]!.endMinutes <= st.startMinutes) {
2095
- // This column is free
2096
- break;
2097
- }
2098
- }
2099
-
2100
- // If no existing column works, create a new one
2101
- if (columnIndex === columns.length) {
2102
- columns.push({ endMinutes: st.endMinutes });
2103
- } else {
2104
- columns[columnIndex]!.endMinutes = st.endMinutes;
2105
- }
2106
-
2107
- const segmentKey = `${st.segment.weekIndex}-${st.segment.event.id}-${dayIndex}`;
2108
- segmentColumns.set(segmentKey, {
2109
- column: columnIndex,
2110
- totalColumns: columns.length,
2111
- });
2112
- }
2113
-
2114
- // Update total columns for all segments in this day
2115
- for (const st of segmentTimes) {
2116
- const segmentKey = `${st.segment.weekIndex}-${st.segment.event.id}-${dayIndex}`;
2117
- const layout = segmentColumns.get(segmentKey);
2118
- if (layout) {
2119
- layout.totalColumns = columns.length;
2120
- }
2121
- }
2122
- }
2123
-
2124
- // Render segments
2125
- for (const segment of segments) {
2126
- const {
2127
- event,
2128
- week,
2129
- weekIndex,
2130
- startDayIndex,
2131
- endDayIndex,
2132
- isStart,
2133
- isEnd,
2134
- totalWeeks,
2135
- } = segment;
2136
- const weekYOffset = week.yOffset;
2137
-
2138
- const allDay = event.isAllDay === true;
2139
-
2140
- // Get visual position for the start day
2141
- const startVisualPos = this.getDayVisualPosition(startDayIndex);
2142
- const endVisualPos = this.getDayVisualPosition(endDayIndex);
2143
-
2144
- // Skip segments that span multiple visual rows for now (would need to be split)
2145
- // For simple case, render each day on its visual row
2146
- if (startVisualPos.row !== endVisualPos.row) {
2147
- // Event spans visual rows - for now, only render the start portion
2148
- // TODO: Split into multiple segments across visual rows
2149
- }
2150
-
2151
- let yStart: number;
2152
- let yEnd: number;
2153
-
2154
- if (showTimeScale && !allDay) {
2155
- const dayStartTime = new Date(week.days[startDayIndex]!).setHours(
2156
- 0,
2157
- 0,
2158
- 0,
2159
- 0,
2160
- );
2161
- const dayEndTime = new Date(week.days[endDayIndex]!).setHours(
2162
- 23,
2163
- 59,
2164
- 59,
2165
- 999,
2166
- );
2167
- const eventStartTime = event.start.getTime();
2168
- const eventEndTime = event.end.getTime() - 60000; // Subtract 1 minute for rendering
2169
-
2170
- const effectiveStartTime = Math.max(eventStartTime, dayStartTime);
2171
- const effectiveEndTime = Math.min(eventEndTime, dayEndTime);
2172
-
2173
- const effectiveStart = new Date(effectiveStartTime);
2174
- const effectiveEnd = new Date(effectiveEndTime);
2175
-
2176
- const startMinutes =
2177
- effectiveStart.getHours() * 60 + effectiveStart.getMinutes();
2178
- const endMinutes =
2179
- effectiveEnd.getHours() * 60 + effectiveEnd.getMinutes();
2180
-
2181
- // Position within the visual row
2182
- const visualRowY = weekYOffset + startVisualPos.row * this.dayHeight;
2183
- yStart = visualRowY + (startMinutes / 1440) * this.dayHeight;
2184
- yEnd = visualRowY + (endMinutes / 1440) * this.dayHeight;
2185
- } else {
2186
- const eventKey = `${weekIndex}-${event.id}`;
2187
- let rowIndex = eventRowIndex.get(eventKey);
2188
-
2189
- if (rowIndex === undefined) {
2190
- rowIndex = 0;
2191
- while (true) {
2192
- let rowFree = true;
2193
- for (let d = startDayIndex; d <= endDayIndex; d++) {
2194
- const dayKey = `${weekIndex}-${d}`;
2195
- const occupied = dayOccupiedRows.get(dayKey);
2196
- if (occupied?.has(rowIndex)) {
2197
- rowFree = false;
2198
- break;
2199
- }
2200
- }
2201
- if (rowFree) break;
2202
- rowIndex++;
2203
- }
2204
- eventRowIndex.set(eventKey, rowIndex);
2205
- }
2206
-
2207
- for (let d = startDayIndex; d <= endDayIndex; d++) {
2208
- const dayKey = `${weekIndex}-${d}`;
2209
- let occupied = dayOccupiedRows.get(dayKey);
2210
- if (!occupied) {
2211
- occupied = new Set();
2212
- dayOccupiedRows.set(dayKey, occupied);
2213
- }
2214
- occupied.add(rowIndex);
2215
- }
2216
-
2217
- const maxEventsInRow = Math.floor(
2218
- (this.dayHeight - 4) / (MIN_EVENT_HEIGHT + 2),
2219
- );
2220
- if (rowIndex >= maxEventsInRow) continue;
2221
-
2222
- // Position within the visual row
2223
- const visualRowY = weekYOffset + startVisualPos.row * this.dayHeight;
2224
- yStart = visualRowY + 4 + rowIndex * (MIN_EVENT_HEIGHT + 2);
2225
- yEnd = yStart + MIN_EVENT_HEIGHT;
2226
- }
2227
-
2228
- const eventHeight = Math.max(
2229
- showTimeScale ? 4 : MIN_EVENT_HEIGHT,
2230
- yEnd - yStart,
2231
- );
2232
-
2233
- // Calculate horizontal position with column layout for time-scaled events
2234
- let x: number;
2235
- let spanWidth: number;
2236
-
2237
- const segmentKey = `${weekIndex}-${event.id}-${startDayIndex}`;
2238
- const columnLayout = segmentColumns.get(segmentKey);
2239
-
2240
- if (
2241
- showTimeScale &&
2242
- !allDay &&
2243
- columnLayout &&
2244
- columnLayout.totalColumns > 1
2245
- ) {
2246
- // This event has parallel events - divide the day width
2247
- const columnWidth = dayWidth / columnLayout.totalColumns;
2248
- x =
2249
- LEFT_GUTTER_WIDTH +
2250
- startVisualPos.col * dayWidth +
2251
- columnLayout.column * columnWidth;
2252
- spanWidth = columnWidth;
2253
- } else {
2254
- // Normal layout - use visual column position
2255
- // For multi-day events on same visual row, calculate span
2256
- const colSpan = endVisualPos.col - startVisualPos.col + 1;
2257
- x = LEFT_GUTTER_WIDTH + startVisualPos.col * dayWidth;
2258
- spanWidth = colSpan * dayWidth;
2259
- }
2260
-
2261
- // Convert to viewport coordinates
2262
- const viewportY = yStart - scrollTop;
2263
-
2264
- // Skip if completely out of view
2265
- if (viewportY + eventHeight < 0 || viewportY > height) continue;
2266
-
2267
- const eventColor = event.color || "#888888";
2268
- const isSelected = this.internal.isEventSelected(event);
2269
- const isHovered = this.hoveredEventId === event.id;
2270
-
2271
- // Apple Calendar style dimensions
2272
- const padding = 2;
2273
- const eventWidth = spanWidth - padding * 2;
2274
- const leftBorderWidth = 3;
2275
- const contentPadding = 6;
2276
-
2277
- // Determine border radius based on span position
2278
- let radiusTopLeft = 4;
2279
- let radiusTopRight = 4;
2280
- let radiusBottomLeft = 4;
2281
- let radiusBottomRight = 4;
2282
-
2283
- if (totalWeeks > 1) {
2284
- if (isStart && !isEnd) {
2285
- radiusTopRight = 0;
2286
- radiusBottomRight = 0;
2287
- } else if (!isStart && isEnd) {
2288
- radiusTopLeft = 0;
2289
- radiusBottomLeft = 0;
2290
- } else if (!isStart && !isEnd) {
2291
- radiusTopLeft = 0;
2292
- radiusTopRight = 0;
2293
- radiusBottomLeft = 0;
2294
- radiusBottomRight = 0;
2295
- }
2296
- }
2297
-
2298
- ctx.save();
2299
-
2300
- // Apply transparency for read-only events
2301
- if (event.readOnly) {
2302
- ctx.globalAlpha = 0.5;
2303
- }
2304
-
2305
- // Draw background - solid color if selected, subtle if not
2306
- const hsl = rgbToHsl(hexToRgb(eventColor));
2307
- const textPrimary =
2308
- getComputedStyle(this).getPropertyValue("--text-primary").trim() ||
2309
- "rgba(255, 255, 255, 0.9)";
2310
- const textInverse =
2311
- getComputedStyle(this).getPropertyValue("--text-inverse").trim() ||
2312
- "rgb(0, 0, 0)";
2313
- // Increase saturation to 75% for more vibrant colors, allow higher lightness for brighter backgrounds
2314
- const backgroundColor = isSelected
2315
- ? eventColor
2316
- : `hsla(${hsl[0]}, ${Math.min(hsl[1] * 0.9, 90)}%, ${Math.min(
2317
- hsl[2] + 15,
2318
- 40,
2319
- )}%, 0.45)`;
2320
- const borderColor = isSelected
2321
- ? eventColor
2322
- : `hsla(${hsl[0]}, ${Math.min(hsl[1] * 0.9, 90)}%, ${Math.min(
2323
- hsl[2] + 10,
2324
- 70,
2325
- )}%, 1)`;
2326
-
2327
- // Check if event should have stripes (not accepted)
2328
- const useStripes = this.shouldRenderEventWithStripes(event);
2329
-
2330
- ctx.beginPath();
2331
- ctx.roundRect(x + padding, viewportY, eventWidth, eventHeight, [
2332
- radiusTopLeft,
2333
- radiusTopRight,
2334
- radiusBottomRight,
2335
- radiusBottomLeft,
2336
- ]);
2337
-
2338
- // Fill with solid background first
2339
- ctx.fillStyle = backgroundColor;
2340
- ctx.fill();
2341
-
2342
- // Add stripe pattern overlay if needed
2343
- if (useStripes) {
2344
- const patternCanvas = this.getStripePatternCanvas();
2345
- if (patternCanvas) {
2346
- ctx.save();
2347
-
2348
- // Clip to the event rectangle
2349
- ctx.beginPath();
2350
- ctx.roundRect(x + padding, viewportY, eventWidth, eventHeight, [
2351
- radiusTopLeft,
2352
- radiusTopRight,
2353
- radiusBottomRight,
2354
- radiusBottomLeft,
2355
- ]);
2356
- ctx.clip();
2357
-
2358
- // Pattern size is 16px (8 * scale of 2)
2359
- const patternSize = 12;
2360
-
2361
- // Calculate starting position to align with content coordinates
2362
- // Start from a position that makes the pattern appear fixed in content space
2363
- const startX = Math.floor((x + padding) / patternSize) * patternSize;
2364
- const startY =
2365
- Math.floor(yStart / patternSize) * patternSize - scrollTop;
2366
-
2367
- // Tile the pattern across the clipped area
2368
- for (
2369
- let py = startY;
2370
- py < viewportY + eventHeight;
2371
- py += patternSize
2372
- ) {
2373
- for (
2374
- let px = startX;
2375
- px < x + padding + eventWidth;
2376
- px += patternSize
2377
- ) {
2378
- ctx.drawImage(patternCanvas, px, py, patternSize, patternSize);
2379
- }
2380
- }
2381
-
2382
- ctx.restore();
2383
- }
2384
- }
2385
-
2386
- // Draw colored left border (Apple Calendar style)
2387
- if (!isSelected) {
2388
- ctx.fillStyle = borderColor;
2389
- ctx.beginPath();
2390
- ctx.roundRect(
2391
- x + padding + 2,
2392
- viewportY + 2,
2393
- leftBorderWidth,
2394
- eventHeight - 4,
2395
- [radiusTopLeft, radiusTopLeft, radiusBottomLeft, radiusBottomLeft],
2396
- );
2397
- ctx.fill();
2398
- }
2399
-
2400
- // Draw selection border - visible outline for selected events
2401
- if (isSelected) {
2402
- ctx.strokeStyle = textInverse;
2403
- ctx.lineWidth = 1;
2404
- ctx.beginPath();
2405
- ctx.roundRect(x + padding, viewportY, eventWidth, eventHeight, [
2406
- radiusTopLeft,
2407
- radiusTopRight,
2408
- radiusBottomRight,
2409
- radiusBottomLeft,
2410
- ]);
2411
- ctx.stroke();
2412
- }
2413
-
2414
- // Draw hover outline
2415
- if (isHovered && !isSelected) {
2416
- ctx.strokeStyle =
2417
- getComputedStyle(this)
2418
- .getPropertyValue("--grid-color-hover")
2419
- .trim() || "rgba(255, 255, 255, 0.2)";
2420
- ctx.lineWidth = 1;
2421
- ctx.beginPath();
2422
- ctx.roundRect(x + padding, viewportY, eventWidth, eventHeight, [
2423
- radiusTopLeft,
2424
- radiusTopRight,
2425
- radiusBottomRight,
2426
- radiusBottomLeft,
2427
- ]);
2428
- ctx.stroke();
2429
- }
2430
-
2431
- // Draw text if this is the start segment and there's enough space
2432
- if (isStart && eventHeight >= 16) {
2433
- // Use inverse text color for selected events (for contrast against solid color)
2434
- // Use event color or primary text color for unselected events
2435
- const textColor = isSelected
2436
- ? textInverse || "white"
2437
- : textPrimary || eventColor;
2438
- ctx.fillStyle = textColor;
2439
- ctx.font = `11px ${fontFamily}`;
2440
- ctx.textAlign = "left";
2441
- ctx.textBaseline = "top";
2442
-
2443
- const textX = x + padding + leftBorderWidth + contentPadding + 1;
2444
- const textY = viewportY + 6;
2445
- let maxTextWidth = eventWidth - leftBorderWidth - contentPadding - 4;
2446
-
2447
- // Clip text to event bounds
2448
- ctx.save();
2449
- ctx.beginPath();
2450
- ctx.rect(
2451
- x + padding + leftBorderWidth,
2452
- viewportY,
2453
- eventWidth - leftBorderWidth,
2454
- eventHeight,
2455
- );
2456
- ctx.clip();
2457
-
2458
- // Draw recurring icon if event has rrule
2459
- if (event.rrule) {
2460
- ctx.font = `11px ${fontFamily}`;
2461
- const recurIcon = "⟳";
2462
- const iconWidth = ctx.measureText(recurIcon).width;
2463
- ctx.fillText(recurIcon, textX, textY);
2464
- maxTextWidth -= iconWidth + 4;
2465
- }
2466
-
2467
- // Draw title with ellipsis if needed
2468
- const titleStartX =
2469
- textX + (event.rrule ? ctx.measureText("⟳").width + 4 : 0);
2470
- let displayTitle = event.title;
2471
- ctx.font = `11px ${fontFamily}`;
2472
- const titleWidth = ctx.measureText(displayTitle).width;
2473
-
2474
- if (titleWidth > maxTextWidth) {
2475
- const ellipsis = "…";
2476
- const ellipsisWidth = ctx.measureText(ellipsis).width;
2477
-
2478
- // Binary search for the right length
2479
- let left = 0;
2480
- let right = displayTitle.length;
2481
- let bestFit = 0;
2482
-
2483
- while (left <= right) {
2484
- const mid = Math.floor((left + right) / 2);
2485
- const testText = displayTitle.substring(0, mid);
2486
- const testWidth = ctx.measureText(testText).width + ellipsisWidth;
2487
-
2488
- if (testWidth <= maxTextWidth) {
2489
- bestFit = mid;
2490
- left = mid + 1;
2491
- } else {
2492
- right = mid - 1;
2493
- }
2494
- }
2495
-
2496
- displayTitle = displayTitle.substring(0, bestFit) + ellipsis;
2497
- }
2498
-
2499
- ctx.fillText(displayTitle, titleStartX, textY);
2500
-
2501
- // Draw time if there's enough space (at least 32px for title + time)
2502
- if (eventHeight >= 32) {
2503
- const formatTime = (date: Date) => {
2504
- const hours = date.getHours();
2505
- const minutes = date.getMinutes();
2506
- const ampm = hours >= 12 ? "PM" : "AM";
2507
- const displayHours = hours % 12 || 12;
2508
- return `${displayHours}:${minutes
2509
- .toString()
2510
- .padStart(2, "0")} ${ampm}`;
2511
- };
2512
-
2513
- const startTime = formatTime(event.start);
2514
- const endTime = formatTime(event.end);
2515
- const timeText = `${startTime} – ${endTime}`;
2516
-
2517
- // Use inverse text color for selected events, primary text or event color for unselected
2518
- const timeTextColor = isSelected
2519
- ? textInverse || "white"
2520
- : textPrimary || eventColor;
2521
- ctx.fillStyle = timeTextColor;
2522
- ctx.font = `10px ${fontFamily}`;
2523
-
2524
- // Draw time with ellipsis if needed
2525
- let displayTime = timeText;
2526
- let timeWidth = ctx.measureText(displayTime).width;
2527
-
2528
- if (timeWidth > maxTextWidth) {
2529
- const ellipsis = "…";
2530
- const ellipsisWidth = ctx.measureText(ellipsis).width;
2531
-
2532
- let left = 0;
2533
- let right = displayTime.length;
2534
- let bestFit = 0;
2535
-
2536
- while (left <= right) {
2537
- const mid = Math.floor((left + right) / 2);
2538
- const testText = displayTime.substring(0, mid);
2539
- const testWidth = ctx.measureText(testText).width + ellipsisWidth;
2540
-
2541
- if (testWidth <= maxTextWidth) {
2542
- bestFit = mid;
2543
- left = mid + 1;
2544
- } else {
2545
- right = mid - 1;
2546
- }
2547
- }
2548
-
2549
- displayTime = displayTime.substring(0, bestFit) + ellipsis;
2550
- }
2551
-
2552
- ctx.fillText(displayTime, textX, textY + 14);
2553
- }
2554
-
2555
- ctx.restore();
2556
- }
2557
-
2558
- ctx.restore();
2559
-
2560
- // Store rect for hit testing (in content coordinates)
2561
- this.eventRects.push({
2562
- event,
2563
- x: x + padding,
2564
- y: yStart,
2565
- width: eventWidth,
2566
- height: eventHeight,
2567
- });
2568
- }
2569
-
2570
- // Render month labels
2571
- const monthNames = [
2572
- "January",
2573
- "February",
2574
- "March",
2575
- "April",
2576
- "May",
2577
- "June",
2578
- "July",
2579
- "August",
2580
- "September",
2581
- "October",
2582
- "November",
2583
- "December",
2584
- ];
2585
-
2586
- const monthBoundaries: {
2587
- monthKey: string;
2588
- monthName: string;
2589
- year: number;
2590
- yOffset: number;
2591
- }[] = [];
2592
- const seenMonths = new Set<string>();
2593
-
2594
- for (const week of visibleWeeks) {
2595
- const firstDay = week.days[0];
2596
- if (!firstDay) continue;
2597
-
2598
- const monthIndex = firstDay.getMonth();
2599
- const year = firstDay.getFullYear();
2600
- const monthKey = `${monthIndex}-${year}`;
2601
-
2602
- if (!seenMonths.has(monthKey)) {
2603
- seenMonths.add(monthKey);
2604
- const monthName = monthNames[monthIndex];
2605
- if (monthName) {
2606
- monthBoundaries.push({
2607
- monthKey,
2608
- monthName,
2609
- year,
2610
- yOffset: week.yOffset,
2611
- });
2612
- }
2613
- }
2614
- }
2615
-
2616
- for (let i = 0; i < monthBoundaries.length; i++) {
2617
- const month = monthBoundaries[i]!;
2618
- const nextMonth = monthBoundaries[i + 1];
2619
- const labelY = month.yOffset;
2620
- const nextMonthY = nextMonth ? nextMonth.yOffset : this.totalHeight;
2621
-
2622
- if (nextMonthY < scrollTop) continue;
2623
- if (labelY > viewportBottom) break;
2624
-
2625
- const padding = [12, 0, 0, 12];
2626
-
2627
- const stickyTop = Math.max(0, scrollTop - labelY);
2628
- const maxStickyTop = nextMonthY - labelY - 24;
2629
- const clampedStickyTop = Math.min(stickyTop, maxStickyTop);
2630
- const labelTopMargin = 32;
2631
- const finalTop = labelY + clampedStickyTop - scrollTop + labelTopMargin;
2632
-
2633
- ctx.save();
2634
- ctx.font = `bold 18px ${fontFamily}`;
2635
- ctx.textAlign = "left";
2636
- ctx.textBaseline = "top";
2637
-
2638
- const labelText = `${month.monthName} ${month.year}`;
2639
- const textWidth = ctx.measureText(labelText).width;
2640
- const leftMargin = 8;
2641
- const textX = 64 + padding[3] + leftMargin;
2642
- const textY = finalTop + padding[0];
2643
-
2644
- // Draw background
2645
- const bgPaddingLeft = 8;
2646
- const bgPaddingRight = 8;
2647
- const bgElevated =
2648
- getComputedStyle(this).getPropertyValue("--bg-elevated").trim() ||
2649
- "rgba(0, 0, 0, 0.7)";
2650
- ctx.fillStyle = bgElevated;
2651
- ctx.beginPath();
2652
- ctx.roundRect(
2653
- textX - bgPaddingLeft,
2654
- textY - 4,
2655
- textWidth + bgPaddingLeft + bgPaddingRight,
2656
- 26,
2657
- 6,
2658
- );
2659
- ctx.fill();
2660
-
2661
- // Draw text
2662
- const textPrimary =
2663
- getComputedStyle(this).getPropertyValue("--text-primary").trim() ||
2664
- "rgba(255, 255, 255, 0.95)";
2665
- ctx.fillStyle = textPrimary;
2666
- ctx.fillText(labelText, textX, textY);
2667
- ctx.restore();
2668
- }
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();
2669
1795
  }
2670
1796
 
1797
+
2671
1798
  onWheel = (e: WheelEvent): void => {
2672
1799
  if (this.hasAttribute("scroll-lock")) return;
1800
+ this.lastWheelTime = Date.now();
2673
1801
 
2674
1802
  if (this.scrollAnimationFrame) {
2675
1803
  this.cancelScrollAnimation();
@@ -3057,9 +2185,10 @@ export class CalendarViewElement extends LitElement {
3057
2185
  snappedEnd.setTime(snappedStart.getTime() + 15 * 60 * 1000);
3058
2186
  }
3059
2187
 
2188
+ const isZoomedOut = this.dayHeight < TIME_SCALE_DAY_HEIGHT;
3060
2189
  this.dispatchEvent(
3061
2190
  new CustomEvent("create-event", {
3062
- detail: { start: snappedStart, end: snappedEnd },
2191
+ detail: { start: snappedStart, end: snappedEnd, isAllDay: isZoomedOut },
3063
2192
  bubbles: true,
3064
2193
  }),
3065
2194
  );
@@ -3247,16 +2376,16 @@ export class CalendarViewElement extends LitElement {
3247
2376
  // Update cursor position for status bar
3248
2377
  this.cursorPosition = { x, y };
3249
2378
 
3250
- // Check for resize handles first
3251
- 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;
3252
2381
  if (resizeHandle && !this.isResizingEvent && !this.isDraggingEvent) {
3253
2382
  this.scrollContainer.style.cursor = "ns-resize";
3254
2383
  } else if (!this.isResizingEvent && !this.isDraggingEvent) {
3255
2384
  this.scrollContainer.style.cursor = "";
3256
2385
  }
3257
2386
 
3258
- // Check for event hover
3259
- 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;
3260
2389
  const newHoveredId = hoveredEvent ? hoveredEvent.id : null;
3261
2390
 
3262
2391
  if (newHoveredId !== this.hoveredEventId) {
@@ -3414,6 +2543,31 @@ export class CalendarViewElement extends LitElement {
3414
2543
  }
3415
2544
  };
3416
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
+
3417
2571
  onPaste = async (e: ClipboardEvent): Promise<void> => {
3418
2572
  const files = e.clipboardData?.files;
3419
2573
  if (files && files.length > 0) {
@@ -3659,13 +2813,13 @@ export class CalendarViewElement extends LitElement {
3659
2813
  *
3660
2814
  * Selection box is 2D with different behavior based on zoom level:
3661
2815
  *
3662
- * ZOOMED OUT (dayHeight < 200):
2816
+ * ZOOMED OUT (dayHeight < TIME_SCALE_DAY_HEIGHT):
3663
2817
  * - X axis: days of the week (horizontal)
3664
2818
  * - Y axis: multiple weeks stacked (vertical)
3665
2819
  * - Creates time ranges per WEEK for selected day range
3666
2820
  * - Example: Wed-Thu across 3 weeks = 3 ranges (one per week)
3667
2821
  *
3668
- * ZOOMED IN (dayHeight >= 200, time scale visible):
2822
+ * ZOOMED IN (dayHeight >= TIME_SCALE_DAY_HEIGHT, time scale visible):
3669
2823
  * - X axis: days of the week (horizontal)
3670
2824
  * - Y axis: time within days (vertical, with weeks)
3671
2825
  * - Creates time ranges per DAY for selected time range
@@ -3711,7 +2865,7 @@ export class CalendarViewElement extends LitElement {
3711
2865
  if (intersectingWeeks.length === 0) return;
3712
2866
 
3713
2867
  const timeRanges: Array<{ start: Date; end: Date }> = [];
3714
- const isZoomedIn = this.dayHeight >= 200;
2868
+ const isZoomedIn = this.dayHeight >= TIME_SCALE_DAY_HEIGHT;
3715
2869
 
3716
2870
  if (isZoomedIn) {
3717
2871
  // ZOOMED IN: Create time ranges per DAY (same time across multiple weeks)
@@ -3800,6 +2954,10 @@ export class CalendarViewElement extends LitElement {
3800
2954
  for (const event of this.events) {
3801
2955
  // Skip all-day events
3802
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;
3803
2961
 
3804
2962
  const eventStartTime = event.start.getTime();
3805
2963
  const eventEndTime = event.end.getTime();
@@ -3891,9 +3049,17 @@ export class CalendarViewElement extends LitElement {
3891
3049
  const scrollTop = this.scrollTop;
3892
3050
  const scrollBottom = scrollTop + this.viewportHeight;
3893
3051
 
3052
+ const nearTop = scrollTop < bufferHeight;
3053
+ const nearBottom = scrollBottom > this.totalHeight - bufferHeight;
3054
+ if (!nearTop && !nearBottom) return;
3055
+
3894
3056
  // Check if near top (extending to past)
3895
- if (scrollTop < bufferHeight) {
3057
+ if (nearTop) {
3896
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;
3897
3063
  const newWeeks = this.internal.extendRange("past");
3898
3064
 
3899
3065
  // Prepend new weeks
@@ -3912,11 +3078,13 @@ export class CalendarViewElement extends LitElement {
3912
3078
  // Must update DOM first so scroll container has correct height
3913
3079
  this.requestUpdate();
3914
3080
 
3915
- // Adjust scroll position to maintain visual position (after DOM update)
3916
3081
  requestAnimationFrame(() => {
3917
- this._scrollTop += addedHeight;
3918
- if (this.scrollContainer) {
3919
- 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
+ }
3920
3088
  }
3921
3089
 
3922
3090
  this.renderCanvas();
@@ -3927,7 +3095,7 @@ export class CalendarViewElement extends LitElement {
3927
3095
  }
3928
3096
 
3929
3097
  // Check if near bottom (extending to future)
3930
- if (scrollBottom > this.totalHeight - bufferHeight) {
3098
+ if (nearBottom) {
3931
3099
  this.isExtendingRange = true;
3932
3100
  const newWeeks = this.internal.extendRange("future");
3933
3101
 
@@ -4200,6 +3368,7 @@ export class CalendarViewElement extends LitElement {
4200
3368
  fill: accent.replace("rgb", "rgba").replace(")", ", 0.3)"),
4201
3369
  stroke: accent.replace("rgb", "rgba").replace(")", ", 0.8)"),
4202
3370
  text: "white",
3371
+ dashed: false,
4203
3372
  },
4204
3373
  useStartEdge,
4205
3374
  );
@@ -4212,6 +3381,7 @@ export class CalendarViewElement extends LitElement {
4212
3381
  fill: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.5)`,
4213
3382
  stroke: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.5)`,
4214
3383
  text: "rgba(255, 255, 255, 0.5)",
3384
+ dashed: false,
4215
3385
  },
4216
3386
  useStartEdge,
4217
3387
  );
@@ -4256,8 +3426,13 @@ export class CalendarViewElement extends LitElement {
4256
3426
  later = startDate < endDate ? endDate : startDate;
4257
3427
  }
4258
3428
 
4259
- earlier.setMinutes(Math.round(earlier.getMinutes() / 15) * 15, 0, 0);
4260
- 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
+ }
4261
3436
 
4262
3437
  // Use active calendar color if available, otherwise fall back to accent-primary
4263
3438
  let fill: string;
@@ -4285,7 +3460,7 @@ export class CalendarViewElement extends LitElement {
4285
3460
  renderVirtualEvent(
4286
3461
  start: Date,
4287
3462
  end: Date,
4288
- color: { fill: string; stroke: string; text: string },
3463
+ color: { fill: string; stroke: string; text: string; dashed?: boolean },
4289
3464
  useStartEdge = true,
4290
3465
  ): void {
4291
3466
  if (!this.overlayCanvas || !this.overlayCtx || !this.scrollContainer)
@@ -4335,7 +3510,7 @@ export class CalendarViewElement extends LitElement {
4335
3510
  ctx.fill();
4336
3511
  ctx.strokeStyle = color.stroke;
4337
3512
  ctx.lineWidth = 1;
4338
- ctx.setLineDash([6, 3]);
3513
+ if (color.dashed !== false) ctx.setLineDash([6, 3]);
4339
3514
  ctx.stroke();
4340
3515
  ctx.setLineDash([]);
4341
3516
  };
@@ -4819,7 +3994,7 @@ export class CalendarViewElement extends LitElement {
4819
3994
  let hour = 0;
4820
3995
  let minute = 0;
4821
3996
 
4822
- if (this.dayHeight >= 200) {
3997
+ if (this.dayHeight >= TIME_SCALE_DAY_HEIGHT) {
4823
3998
  const offsetInRow = y - (week.yOffset + rowInWeek * this.dayHeight);
4824
3999
  const minutes = Math.floor((offsetInRow / this.dayHeight) * 24 * 60);
4825
4000
  hour = Math.floor(minutes / 60);
@@ -4903,6 +4078,7 @@ export class CalendarViewElement extends LitElement {
4903
4078
  formattedCursorDate: cursorTime
4904
4079
  ? this.formatCompactDate(cursorTime.date)
4905
4080
  : "",
4081
+ altKeyActive: this.altKeyActive,
4906
4082
  };
4907
4083
  }
4908
4084
 
@@ -5229,16 +4405,20 @@ export class CalendarViewElement extends LitElement {
5229
4405
  </div>
5230
4406
 
5231
4407
  <div class="event-detail-body">
5232
- ${
5233
- event.location
5234
- ? html`
4408
+ ${(() => {
4409
+ const teamsMatch = event.description?.match(/https:\/\/teams\.microsoft\.com\/[^\s<>"]+/);
4410
+ const teamsUrl = teamsMatch ? teamsMatch[0] : null;
4411
+ if (!event.location && !teamsUrl) return null;
4412
+ return html`
5235
4413
  <div class="event-detail-section">
5236
4414
  <div class="event-detail-label">Location</div>
5237
- <div class="event-detail-value">${event.location}</div>
4415
+ <div class="event-detail-value">
4416
+ ${event.location ? html`<div>${event.location}</div>` : null}
4417
+ ${teamsUrl ? html`<a href="${teamsUrl}" class="event-detail-link" target="_blank" rel="noopener">Join Microsoft Teams Meeting</a>` : null}
4418
+ </div>
5238
4419
  </div>
5239
- `
5240
- : null
5241
- }
4420
+ `;
4421
+ })()}
5242
4422
 
5243
4423
  ${
5244
4424
  event.url
@@ -5477,70 +4657,8 @@ export class CalendarViewElement extends LitElement {
5477
4657
  render() {
5478
4658
  return html`
5479
4659
  <div class="container ${this.isDraggingFile ? "dragging-file" : ""}">
5480
- <div class="toolbar">
5481
- <div class="toolbar-left">
5482
- <slot name="toolbar-center"></slot>
5483
-
5484
- <button class="toolbar-button" title="Month" @click=${
5485
- this.scrollToMonth
5486
- }>
5487
- Month
5488
- </button>
5489
- <button class="toolbar-button" title="Today" @click=${
5490
- this.scrollToToday
5491
- }>
5492
- Today
5493
- </button>
5494
- <button ?disabled=${
5495
- this.historyStack.length === 0 ||
5496
- this.historyIndex >= this.historyStack.length - 1
5497
- } class="toolbar-button" title="Back" @click=${this.goBack}>
5498
-
5499
- </button>
5500
- <button ?disabled=${
5501
- this.historyStack.length === 0 || this.historyIndex === 0
5502
- } class="toolbar-button" title="Forward" @click=${this.goForward}>
5503
-
5504
- </button>
5505
- <div class="toolbar-zoom">
5506
- <input
5507
- type="range"
5508
- class="toolbar-zoom-slider"
5509
- min="${MIN_DAY_HEIGHT}"
5510
- max="${MAX_DAY_HEIGHT}"
5511
- .value=${this.dayHeight}
5512
- @input=${this.onZoomSliderChange}
5513
- title="Adjust zoom level"
5514
- />
5515
- </div>
5516
- </div>
5517
-
5518
- <div class="toolbar-right">
5519
- <!--<select
5520
- class="theme-selector"
5521
- .value=${this.currentTheme}
5522
- @change=${this.onThemeChange}
5523
- title="Select theme"
5524
- >
5525
- ${availableThemes.map(
5526
- (theme) =>
5527
- html`<option value="${theme.name}">${theme.label}</option>`,
5528
- )}
5529
- </select>-->
5530
- <div class="toolbar-search">
5531
- <span class="toolbar-search-icon">🔍</span>
5532
- <input
5533
- class="toolbar-search-input"
5534
- type="text"
5535
- placeholder="Filter events..."
5536
- .value=${this.filter}
5537
- @input=${this.onFilterInput}
5538
- />
5539
- </div>
5540
- </div>
5541
- </div>
5542
-
5543
4660
  <div class="body">
4661
+ <slot name="sidebar"></slot>
5544
4662
  <div class="calendar-area">
5545
4663
  <div class="canvas-layer">
5546
4664
  <canvas></canvas>
@@ -5560,6 +4678,67 @@ export class CalendarViewElement extends LitElement {
5560
4678
  ${this.minimapCanvas}
5561
4679
  ${this.renderEventDetail()}
5562
4680
  ${this.renderNotificationPopover()}
4681
+
4682
+ <div class="toolbar">
4683
+ <div class="toolbar-left">
4684
+ <button class="toolbar-button" title="Month" @click=${
4685
+ this.scrollToMonth
4686
+ }>
4687
+ Month
4688
+ </button>
4689
+ <button class="toolbar-button" title="Today" @click=${
4690
+ this.scrollToToday
4691
+ }>
4692
+ Today
4693
+ </button>
4694
+ <button ?disabled=${
4695
+ this.historyStack.length === 0 ||
4696
+ this.historyIndex >= this.historyStack.length - 1
4697
+ } class="toolbar-button" title="Back" @click=${this.goBack}>
4698
+
4699
+ </button>
4700
+ <button ?disabled=${
4701
+ this.historyStack.length === 0 || this.historyIndex === 0
4702
+ } class="toolbar-button" title="Forward" @click=${this.goForward}>
4703
+
4704
+ </button>
4705
+ <div class="toolbar-zoom">
4706
+ <input
4707
+ type="range"
4708
+ class="toolbar-zoom-slider"
4709
+ min="${MIN_DAY_HEIGHT}"
4710
+ max="${MAX_DAY_HEIGHT}"
4711
+ .value=${this.dayHeight}
4712
+ @input=${this.onZoomSliderChange}
4713
+ title="Adjust zoom level"
4714
+ />
4715
+ </div>
4716
+ </div>
4717
+
4718
+ <div class="toolbar-right">
4719
+ <!--<select
4720
+ class="theme-selector"
4721
+ .value=${this.currentTheme}
4722
+ @change=${this.onThemeChange}
4723
+ title="Select theme"
4724
+ >
4725
+ ${availableThemes.map(
4726
+ (theme) =>
4727
+ html`<option value="${theme.name}">${theme.label}</option>`,
4728
+ )}
4729
+ </select>-->
4730
+ <div class="toolbar-search">
4731
+ <span class="toolbar-search-icon">🔍</span>
4732
+ <input
4733
+ class="toolbar-search-input"
4734
+ type="text"
4735
+ placeholder="Filter events..."
4736
+ .value=${this.filter}
4737
+ @input=${this.onFilterInput}
4738
+ />
4739
+ </div>
4740
+ </div>
4741
+ </div>
5563
4742
  </div>
5564
4743
  </div>
5565
4744
  </div>
@@ -5638,18 +4817,3 @@ export class CalendarViewElement extends LitElement {
5638
4817
  `;
5639
4818
  }
5640
4819
  }
5641
-
5642
- // Helper to get day index (0-6) within a week for a given date
5643
- function getDayIndexInWeek(week: WeekInfo, date: Date) {
5644
- const dateStart = new Date(date).setHours(0, 0, 0, 0);
5645
- for (let i = 0; i < 7; i++) {
5646
- const weekDay = week.days[i];
5647
- if (weekDay && new Date(weekDay).setHours(0, 0, 0, 0) === dateStart) {
5648
- return i;
5649
- }
5650
- }
5651
- // Date is before or after this week
5652
- const weekStart = new Date(week.days[0]!).setHours(0, 0, 0, 0);
5653
- if (dateStart < weekStart) return 0;
5654
- return 6;
5655
- }