@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.
- package/dist/calendar.js +1635 -1446
- package/package.json +2 -2
- package/src/CalDAVConfig.ts +241 -117
- package/src/CalendarInternal.ts +21 -4
- package/src/CalendarLayer.ts +28 -0
- package/src/CalendarStorage.ts +5 -0
- package/src/CalendarView.ts +360 -1196
- package/src/GoogleCalendarSource.ts +25 -0
- package/src/IndexedDBStorage.ts +16 -0
- package/src/InhouseBookingSource.ts +30 -1
- package/src/Keybinds.ts +3 -18
- package/src/StatusBar.ts +11 -0
- package/src/Theme.ts +4 -4
- package/src/TimeseriesJson.ts +114 -0
- package/src/app.css +17 -1
- package/src/app.ts +211 -186
- package/src/layers/EventsLayer.ts +958 -0
- package/src/layers/GridLayer.ts +296 -0
- package/src/layers/TimeseriesHeatmapLayer.ts +132 -0
- package/src/lib.ts +1 -0
package/src/CalendarView.ts
CHANGED
|
@@ -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 =
|
|
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-
|
|
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:
|
|
64
|
-
left:
|
|
65
|
-
right: 0
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
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
|
-
//
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
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
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
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 <
|
|
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 >=
|
|
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 >=
|
|
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 (
|
|
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
|
-
|
|
3918
|
-
|
|
3919
|
-
this.
|
|
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 (
|
|
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
|
-
|
|
4260
|
-
|
|
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 >=
|
|
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.
|
|
5234
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
-
}
|