530 lines
19 KiB
PHP
530 lines
19 KiB
PHP
<?php
|
||
$days = 2;
|
||
$firstDay = "2026-04-18";
|
||
|
||
$dates = [];
|
||
|
||
$startDate = new DateTime($firstDay);
|
||
|
||
for ($i = 0; $i < $days; $i++) {
|
||
$current = clone $startDate;
|
||
$current->modify("+{$i} day");
|
||
$dates[] = $current->format("Y-m-d");
|
||
}
|
||
|
||
setlocale(LC_TIME, 'de_DE.UTF-8'); // set German locale
|
||
|
||
?>
|
||
|
||
<div class="calendar">
|
||
|
||
<!-- empty top-left corner -->
|
||
<div class="corner fBorderRight fBorderBottom"></div>
|
||
|
||
<!-- headers -->
|
||
|
||
<?php foreach ($dates as $ind => $d): ?>
|
||
<div class="day-header fBorderBottom"><h3><?= strftime("%a", strtotime($d)) ?> Halle</h3></div>
|
||
<div class="day-header fBorderBottom"><h3><?= strftime("%a", strtotime($d)) ?> Aula</h3></div>
|
||
<?php $notLast = ($ind !== count($dates) - 1) ? 'fBorderRight' : '';?>
|
||
<div class="day-header <?= $notLast ?> fBorderBottom"><h3><?= strftime("%a", strtotime($d)) ?> Sonstige</h3></div>
|
||
<?php endforeach; ?>
|
||
|
||
<!-- time column -->
|
||
<div class="time-column fBorderRight">
|
||
<?php for ($i = 0; $i <= 23; $i++) {
|
||
$si = str_pad($i, 2, '0', STR_PAD_LEFT);
|
||
echo '<div class="time">'.$si.':00</div>';
|
||
} ?>
|
||
</div>
|
||
|
||
<!-- day columns -->
|
||
|
||
<?php foreach ($dates as $ind => $d): ?>
|
||
<div class="day-column" data-day="<?= $d ?>" data-type="halle"></div>
|
||
<div class="day-column" data-day="<?= $d ?>" data-type="aula"></div>
|
||
<?php $notLast = ($ind !== count($dates) - 1) ? 'fBorderRight' : '';?>
|
||
<div class="day-column <?= $notLast ?>" data-day="<?= $d ?>" data-type="sonstige"></div>
|
||
<?php endforeach; ?>
|
||
|
||
</div>
|
||
|
||
<button id="exportCal">Export Calendar</button>
|
||
|
||
<style>
|
||
.calendar {
|
||
display: grid;
|
||
grid-template-columns: 80px <?php foreach ($dates as $d) { echo '1fr 1fr 1fr ';}?>;
|
||
grid-template-rows: 40px auto;
|
||
border: 1px solid #ccc;
|
||
font-family: Arial, sans-serif;
|
||
position: relative;
|
||
}
|
||
|
||
.corner {
|
||
border-right: 1px solid #ccc;
|
||
border-bottom: 1px solid #ccc;
|
||
}
|
||
|
||
.day-header {
|
||
position: relative;
|
||
text-align: center;
|
||
font-weight: bold;
|
||
border-bottom: 1px solid #ccc;
|
||
line-height: 40px;
|
||
}
|
||
|
||
.day-header h3 {
|
||
margin: 0;
|
||
}
|
||
|
||
.time-column {
|
||
border-right: 1px solid #ccc;
|
||
}
|
||
|
||
.time {
|
||
height: 90px;
|
||
padding: 5px;
|
||
font-size: 12px;
|
||
box-sizing: border-box;
|
||
border-bottom: 1px solid #eee;
|
||
}
|
||
|
||
.day-column {
|
||
position: relative;
|
||
border-right: 1px solid #ccc;
|
||
}
|
||
|
||
.event {
|
||
position: absolute;
|
||
border-radius: 4px;
|
||
padding: 4px;
|
||
font-size: 12px;
|
||
cursor: grab;
|
||
transition: left 0.2s, width 0.2s;
|
||
}
|
||
|
||
.event.dragging {
|
||
opacity: 0.8;
|
||
z-index: 1000;
|
||
cursor: grabbing;
|
||
}
|
||
|
||
.event div span {
|
||
position: absolute;
|
||
top: 6px;
|
||
left: 10px;
|
||
}
|
||
|
||
.fBorderRight { border-right: 3px solid #000; }
|
||
.fBorderBottom { border-bottom: 3px solid #000; }
|
||
|
||
.day-column.highlight {
|
||
background-color: rgba(255, 255, 0, 0.1);
|
||
}
|
||
|
||
.day-column.highlight-active {
|
||
background-color: rgba(255, 255, 0, 0.25);
|
||
}
|
||
|
||
</style>
|
||
|
||
<script>
|
||
const events = [
|
||
|
||
{ day: "2026-04-18", start: 12.0, end: 13.0, title: "Kampfrichteressen", type: "sonstige", abt: "0", elem: "" },
|
||
// Day 1 - 2026-04-18
|
||
{ day: "2026-04-18", start: 8.0, end: 8.50, title: "Körperliches Aufwärmen", type: "aula", abt: "1", elem: "15" },
|
||
{ day: "2026-04-18", start: 8.50, end: 9.0, title: "Geführtes Einturnen", type: "halle", abt: "1", elem: "15" },
|
||
{ day: "2026-04-18", start: 9.0, end: 10.0, title: "Wettkampf", type: "halle", abt: "1", elem: "15" },
|
||
{ day: "2026-04-18", start: 10.0, end: 10.50, title: "Rangverkündigung", type: "aula", abt: "1", elem: "15" },
|
||
|
||
{ day: "2026-04-18", start: 10.50, end: 11.0, title: "Körperliches Aufwärmen", type: "aula", abt: "2", elem: "13" },
|
||
{ day: "2026-04-18", start: 11.0, end: 11.50, title: "Geführtes Einturnen", type: "halle", abt: "2", elem: "13" },
|
||
{ day: "2026-04-18", start: 11.50, end: 12.50, title: "Wettkampf", type: "halle", abt: "2", elem: "13" },
|
||
{ day: "2026-04-18", start: 12.50, end: 13.0, title: "Rangverkündigung", type: "aula", abt: "2", elem: "13" },
|
||
|
||
{ day: "2026-04-18", start: 13.0, end: 13.50, title: "Körperliches Aufwärmen", type: "aula", abt: "3", elem: "10" },
|
||
{ day: "2026-04-18", start: 13.50, end: 14.0, title: "Geführtes Einturnen", type: "halle", abt: "3", elem: "10" },
|
||
{ day: "2026-04-18", start: 14.0, end: 15.0, title: "Wettkampf", type: "halle", abt: "3", elem: "10" },
|
||
{ day: "2026-04-18", start: 15.0, end: 15.50, title: "Rangverkündigung", type: "aula", abt: "3", elem: "10" },
|
||
|
||
{ day: "2026-04-18", start: 15.50, end: 16.0, title: "Körperliches Aufwärmen", type: "aula", abt: "4", elem: "12" },
|
||
{ day: "2026-04-18", start: 16.0, end: 16.50, title: "Geführtes Einturnen", type: "halle", abt: "4", elem: "12" },
|
||
{ day: "2026-04-18", start: 16.50, end: 17.50, title: "Wettkampf", type: "halle", abt: "4", elem: "12" },
|
||
{ day: "2026-04-18", start: 17.30, end: 18.0, title: "Rangverkündigung", type: "aula", abt: "4", elem: "12" },
|
||
|
||
{ day: "2026-04-18", start: 18.0, end: 18.50, title: "Körperliches Aufwärmen", type: "aula", abt: "5", elem: "11" },
|
||
{ day: "2026-04-18", start: 18.50, end: 19.0, title: "Geführtes Einturnen", type: "halle", abt: "5", elem: "11" },
|
||
{ day: "2026-04-18", start: 19.0, end: 20.0, title: "Wettkampf", type: "halle", abt: "5", elem: "11" },
|
||
{ day: "2026-04-18", start: 20.0, end: 20.50, title: "Rangverkündigung", type: "aula", abt: "5", elem: "11" },
|
||
|
||
{ day: "2026-04-18", start: 20.50, end: 21.0, title: "Körperliches Aufwärmen", type: "aula", abt: "6", elem: "14" },
|
||
{ day: "2026-04-18", start: 21.0, end: 21.50, title: "Geführtes Einturnen", type: "halle", abt: "6", elem: "14" },
|
||
{ day: "2026-04-18", start: 21.50, end: 22.50, title: "Wettkampf", type: "halle", abt: "6", elem: "14" },
|
||
{ day: "2026-04-18", start: 22.50, end: 23.0, title: "Rangverkündigung", type: "aula", abt: "6", elem: "14" },
|
||
|
||
// Day 2 - 2026-04-19 (same pattern with shifted times)
|
||
{ day: "2026-04-19", start: 8.0, end: 8.30, title: "Körperliches Aufwärmen", type: "aula", abt: "1", elem: "15" },
|
||
{ day: "2026-04-19", start: 8.30, end: 9.0, title: "Geführtes Einturnen", type: "halle", abt: "1", elem: "15" },
|
||
{ day: "2026-04-19", start: 9.0, end: 10.0, title: "Wettkampf", type: "halle", abt: "1", elem: "15" },
|
||
{ day: "2026-04-19", start: 10.0, end: 10.30, title: "Rangverkündigung", type: "aula", abt: "1", elem: "15" },
|
||
|
||
{ day: "2026-04-19", start: 10.30, end: 11.0, title: "Körperliches Aufwärmen", type: "aula", abt: "2", elem: "13" },
|
||
{ day: "2026-04-19", start: 11.0, end: 11.30, title: "Geführtes Einturnen", type: "halle", abt: "2", elem: "13" },
|
||
{ day: "2026-04-19", start: 11.30, end: 12.30, title: "Wettkampf", type: "halle", abt: "2", elem: "13" },
|
||
{ day: "2026-04-19", start: 12.30, end: 13.0, title: "Rangverkündigung", type: "aula", abt: "2", elem: "13" },
|
||
|
||
// ...repeat for abt 3–6 with times staggered
|
||
];
|
||
|
||
const pixelsPerHour = 90;
|
||
|
||
// Convert hour float to Date
|
||
function hourFloatToDate(day, hourFloat) {
|
||
const d = new Date(day);
|
||
const hours = Math.floor(hourFloat);
|
||
const minutes = Math.round((hourFloat % 1) * 60);
|
||
d.setHours(hours, minutes, 0, 0);
|
||
return d;
|
||
}
|
||
|
||
function hoursToHHMM(hours) {
|
||
const h = Math.floor(hours);
|
||
const m = Math.round((hours - h) * 60);
|
||
return `${h.toString().padStart(2,'0')}:${m.toString().padStart(2,'0')}`;
|
||
}
|
||
|
||
// Convert HH:MM string to hours float
|
||
function HHMMToHours(str) {
|
||
const [h, m] = str.split(':').map(Number);
|
||
return h + m/60;
|
||
}
|
||
|
||
// Format Date to hh:mm
|
||
function formatTime(date) {
|
||
return new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(date);
|
||
}
|
||
|
||
// Collision detection
|
||
function eventsCollide(a, b) {
|
||
return a.start < b.end && a.end > b.start && a.day === b.day && a.type === b.type;
|
||
}
|
||
|
||
// Assign columns with proper overlap tracking
|
||
function assignColumns(events) {
|
||
events.sort((a,b) => a.start - b.start);
|
||
const active = [];
|
||
|
||
events.forEach(event => {
|
||
// Remove finished events
|
||
for (let i = active.length-1; i>=0; i--) {
|
||
if (active[i].end <= event.start) active.splice(i,1);
|
||
}
|
||
|
||
// Find first free column index
|
||
let colIndex = 0;
|
||
while(active.some(e => e.columnIndex === colIndex)) colIndex++;
|
||
event.columnIndex = colIndex;
|
||
active.push(event);
|
||
|
||
// Update totalColumns for current overlap group
|
||
const maxColumns = Math.max(...active.map(e => e.columnIndex + 1));
|
||
active.forEach(e => e.totalColumns = maxColumns);
|
||
});
|
||
}
|
||
|
||
// Render events into the calendar
|
||
function renderEvents(events, colors) {
|
||
events.forEach(event => {
|
||
const dayColumn = document.querySelector(`.day-column[data-day="${event.day}"][data-type="${event.type}"]`);
|
||
const el = document.createElement("div");
|
||
el.className = "event";
|
||
|
||
const startDate = hourFloatToDate(event.day, event.start);
|
||
const endDate = hourFloatToDate(event.day, event.end);
|
||
const duration = event.end - event.start; // in hours
|
||
const lineBreak = duration >= 1 ? "<br>" : " "; // use space if < 1h
|
||
|
||
if (event.abt != 0){
|
||
el.innerHTML = `<div><span><b>${event.title} - Abt. ${event.abt}</b> (${event.elem})${lineBreak}${formatTime(startDate)} - ${formatTime(endDate)}</span></div>`;
|
||
} else {
|
||
el.innerHTML = `<div><span><b>${event.title}</b>${lineBreak}${formatTime(startDate)} - ${formatTime(endDate)}</span></div>`;
|
||
}
|
||
|
||
|
||
el.style.background = colors[event.abt].background;
|
||
el.style.color = colors[event.abt].foreground;
|
||
el.style.borderTop = `3px solid ${colors[event.abt].borderTop}`;
|
||
|
||
const widthPercent = 100 / event.totalColumns;
|
||
el.style.top = (event.start * pixelsPerHour) + "px";
|
||
el.style.height = ((event.end - event.start) * pixelsPerHour - 11) + "px";
|
||
el.style.width = `calc(${widthPercent}% - 10px)`;
|
||
el.style.left = `${event.columnIndex * widthPercent}%`;
|
||
|
||
// Store reference for updates
|
||
event.el = el;
|
||
|
||
addDragBehavior(el, event, colors);
|
||
|
||
dayColumn.appendChild(el);
|
||
});
|
||
}
|
||
|
||
// Drag & drop behavior
|
||
function addDragBehavior(el, event, colors) {
|
||
el.addEventListener("pointerdown", e => {
|
||
e.preventDefault();
|
||
el.setPointerCapture(e.pointerId);
|
||
el.classList.add("dragging");
|
||
|
||
const startY = e.clientY;
|
||
const origTop = parseFloat(el.style.top);
|
||
const type = event.type;
|
||
|
||
const validColumns = Array.from(document.querySelectorAll(`.day-column[data-type="${type}"]`));
|
||
validColumns.forEach(c => c.classList.add("highlight"));
|
||
|
||
function onPointerMove(eMove) {
|
||
const deltaY = eMove.clientY - startY;
|
||
|
||
// Vertical movement (snapped to 15 min)
|
||
let newTop = origTop + deltaY;
|
||
newTop = Math.max(0, newTop);
|
||
newTop = Math.round(newTop / (pixelsPerHour/12)) * (pixelsPerHour/12);
|
||
el.style.top = newTop + "px";
|
||
|
||
// Horizontal movement locked to same type columns
|
||
const pointerX = eMove.clientX;
|
||
const targetColumn = validColumns.find(c => {
|
||
const rect = c.getBoundingClientRect();
|
||
return pointerX >= rect.left && pointerX <= rect.right;
|
||
});
|
||
|
||
if (targetColumn && targetColumn !== el.parentElement) {
|
||
targetColumn.appendChild(el);
|
||
el.style.left = 0;
|
||
}
|
||
|
||
// Update live time
|
||
const newStart = newTop / pixelsPerHour;
|
||
const newEnd = newStart + (event.end - event.start);
|
||
const startDate = hourFloatToDate(event.day, newStart);
|
||
const endDate = hourFloatToDate(event.day, newEnd);
|
||
const duration = event.end - event.start; // in hours
|
||
const lineBreak = duration >= 1 ? "<br>" : " "; // use space if < 1h
|
||
|
||
if (event.abt != 0){
|
||
el.querySelector("span").innerHTML = `<b>${event.title} - Abt. ${event.abt}</b> (${event.elem})${lineBreak}${formatTime(startDate)} - ${formatTime(endDate)}`;
|
||
} else {
|
||
el.querySelector("span").innerHTML = `<b>${event.title}</b>${lineBreak}${formatTime(startDate)} - ${formatTime(endDate)}`;
|
||
}
|
||
}
|
||
|
||
function onPointerUp(eUp) {
|
||
el.classList.remove("dragging");
|
||
el.releasePointerCapture(eUp.pointerId);
|
||
document.removeEventListener("pointermove", onPointerMove);
|
||
document.removeEventListener("pointerup", onPointerUp);
|
||
validColumns.forEach(c => c.classList.remove("highlight"));
|
||
|
||
// Update event day based on column
|
||
const newColumn = validColumns.find(c => el.parentElement === c);
|
||
if(newColumn) event.day = newColumn.dataset.day;
|
||
|
||
// Update start/end
|
||
const newStart = parseFloat(el.style.top) / pixelsPerHour;
|
||
const duration = event.end - event.start;
|
||
event.start = newStart;
|
||
event.end = newStart + duration;
|
||
|
||
// Recalculate columns for current day/type
|
||
const dayEvents = events.filter(ev => ev.day === event.day && ev.type === event.type);
|
||
assignColumns(dayEvents);
|
||
|
||
// Update positions, widths, and displayed time
|
||
dayEvents.forEach(ev => {
|
||
const widthPercent = 100 / ev.totalColumns;
|
||
ev.el.style.width = `calc(${widthPercent}% - 10px)`;
|
||
ev.el.style.left = `${ev.columnIndex * widthPercent}%`;
|
||
|
||
const startDate = hourFloatToDate(ev.day, ev.start);
|
||
const endDate = hourFloatToDate(ev.day, ev.end);
|
||
const duration = ev.end - ev.start; // in hours
|
||
const lineBreak = duration >= 1 ? "<br>" : " "; // use space if < 1h
|
||
|
||
if (ev.abt != 0){
|
||
ev.el.querySelector("span").innerHTML = `<b>${ev.title} - Abt. ${ev.abt}</b> (${ev.elem})${lineBreak}${formatTime(startDate)} - ${formatTime(endDate)}`;
|
||
} else {
|
||
ev.el.querySelector("span").innerHTML = `<b>${ev.title}</b>${lineBreak}${formatTime(startDate)} - ${formatTime(endDate)}`;
|
||
}
|
||
});
|
||
}
|
||
|
||
document.addEventListener("pointermove", onPointerMove);
|
||
document.addEventListener("pointerup", onPointerUp);
|
||
});
|
||
|
||
el.addEventListener("dblclick", () => {
|
||
const durationHours = prompt("Enter new duration in hours:", hoursToHHMM((event.end - event.start).toFixed(2)));
|
||
if(durationHours !== null) {
|
||
const duration = HHMMToHours(durationHours);
|
||
if(!isNaN(duration) && duration > 0) {
|
||
event.end = event.start + duration;
|
||
|
||
// Recalculate columns for the day/type
|
||
const dayEvents = events.filter(ev => ev.day === event.day && ev.type === event.type);
|
||
assignColumns(dayEvents);
|
||
|
||
// Update all event positions, widths, and displayed text
|
||
dayEvents.forEach(ev => {
|
||
const widthPercent = 100 / ev.totalColumns;
|
||
ev.el.style.width = `calc(${widthPercent}% - 10px)`;
|
||
ev.el.style.left = `${ev.columnIndex * widthPercent}%`;
|
||
ev.el.style.top = (ev.start * pixelsPerHour) + "px";
|
||
ev.el.style.height = ((ev.end - ev.start) * pixelsPerHour - 11) + "px";
|
||
|
||
// Update displayed time with conditional line break
|
||
const startDate = hourFloatToDate(ev.day, ev.start);
|
||
const endDate = hourFloatToDate(ev.day, ev.end);
|
||
const dur = ev.end - ev.start;
|
||
const lineBreak = dur >= 1 ? "<br>" : " ";
|
||
if (ev.abt != 0){
|
||
ev.el.querySelector("span").innerHTML = `<b>${ev.title} - Abt. ${ev.abt}</b> (${ev.elem})${lineBreak}${formatTime(startDate)} - ${formatTime(endDate)}`;
|
||
} else {
|
||
ev.el.querySelector("span").innerHTML = `<b>${ev.title}</b>${lineBreak}${formatTime(startDate)} - ${formatTime(endDate)}`;
|
||
}
|
||
});
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// ----------------- USAGE -----------------
|
||
|
||
// Example colors
|
||
const colors = {
|
||
"0": {
|
||
"background": "rgba(172, 172, 172, 0.18)",
|
||
"foreground": "#4b4b4bff",
|
||
"borderTop": "#5a5a5aff"
|
||
},
|
||
"1": {
|
||
"background": "rgba(59, 130, 246, 0.18)",
|
||
"foreground": "#1e3a8a",
|
||
"borderTop": "#1e3a8a"
|
||
},
|
||
"2": {
|
||
"background": "rgba(20, 184, 166, 0.18)",
|
||
"foreground": "#065f46",
|
||
"borderTop": "#065f46"
|
||
},
|
||
"3": {
|
||
"background": "rgba(34, 197, 94, 0.18)",
|
||
"foreground": "#14532d",
|
||
"borderTop": "#14532d"
|
||
},
|
||
"4": {
|
||
"background": "rgba(163, 230, 53, 0.20)",
|
||
"foreground": "#365314",
|
||
"borderTop": "#365314"
|
||
},
|
||
"5": {
|
||
"background": "rgba(245, 158, 11, 0.20)",
|
||
"foreground": "#92400e",
|
||
"borderTop": "#92400e"
|
||
},
|
||
"6": {
|
||
"background": "rgba(249, 115, 22, 0.20)",
|
||
"foreground": "#9a3412",
|
||
"borderTop": "#9a3412"
|
||
},
|
||
"7": {
|
||
"background": "rgba(244, 63, 94, 0.18)",
|
||
"foreground": "#9f1239",
|
||
"borderTop": "#9f1239"
|
||
},
|
||
"8": {
|
||
"background": "rgba(236, 72, 153, 0.18)",
|
||
"foreground": "#9d174d",
|
||
"borderTop": "#9d174d"
|
||
},
|
||
"9": {
|
||
"background": "rgba(168, 85, 247, 0.18)",
|
||
"foreground": "#581c87",
|
||
"borderTop": "#581c87"
|
||
},
|
||
"10": {
|
||
"background": "rgba(99, 102, 241, 0.18)",
|
||
"foreground": "#312e81",
|
||
"borderTop": "#312e81"
|
||
},
|
||
"11": {
|
||
"background": "rgba(100, 116, 139, 0.20)",
|
||
"foreground": "#1e293b",
|
||
"borderTop": "#1e293b"
|
||
},
|
||
"12": {
|
||
"background": "rgba(6, 182, 212, 0.18)",
|
||
"foreground": "#164e63",
|
||
"borderTop": "#164e63"
|
||
}
|
||
};
|
||
|
||
// Initialize
|
||
const grouped = {};
|
||
events.forEach(e => {
|
||
const key = `${e.day}-${e.type}`;
|
||
if(!grouped[key]) grouped[key] = [];
|
||
grouped[key].push(e);
|
||
});
|
||
Object.values(grouped).forEach(arr => assignColumns(arr));
|
||
renderEvents(events, colors);
|
||
|
||
function exportToICS(events) {
|
||
let icsContent = `BEGIN:VCALENDAR
|
||
VERSION:2.0
|
||
PRODID:-//YourApp//Calendar//EN
|
||
CALSCALE:GREGORIAN
|
||
METHOD:PUBLISH
|
||
`;
|
||
|
||
events.forEach(event => {
|
||
const startDate = hourFloatToDate(event.day, event.start);
|
||
const endDate = hourFloatToDate(event.day, event.end);
|
||
|
||
// Format as YYYYMMDDTHHMMSS
|
||
function formatICSDate(d) {
|
||
return d.toISOString().replace(/[-:]/g,'').split('.')[0] + 'Z';
|
||
}
|
||
|
||
icsContent += `BEGIN:VEVENT
|
||
UID:${event.day}-${event.start}-${event.title}
|
||
DTSTAMP:${formatICSDate(new Date())}
|
||
DTSTART:${formatICSDate(startDate)}
|
||
DTEND:${formatICSDate(endDate)}
|
||
SUMMARY:${event.title} - Abt. ${event.abt}
|
||
DESCRIPTION:Type: ${event.type}, Elem: ${event.elem}
|
||
END:VEVENT
|
||
`;
|
||
});
|
||
|
||
icsContent += `END:VCALENDAR`;
|
||
|
||
// Create downloadable link
|
||
const blob = new Blob([icsContent], { type: 'text/calendar;charset=utf-8' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = 'calendar.ics';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
// Attach to button
|
||
document.getElementById('exportCal').addEventListener('click', () => exportToICS(events));
|
||
</script>
|