First version, for githup; UNSTABLE, DO NOT USE!

This commit is contained in:
Fabio Herzig
2026-04-12 21:25:44 +02:00
commit a51fd9dbeb
423 changed files with 58560 additions and 0 deletions

View File

@@ -0,0 +1,529 @@
<?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 36 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>