<%
/* ==========================================================================
KONFIGURATION
========================================================================== */
const calendarConfig = {
/* Startansicht: "month", "week" oder "day" */
defaultView: "month",
/* Sprache für Datumsausgaben */
locale: "de-DE",
/* Erster Wochentag: 1 = Montag, 0 = Sonntag */
firstDayOfWeek: 1,
/* Wochenende hervorheben */
highlightWeekend: true,
/* Kalenderwoche anzeigen */
showWeekNumbers: true,
/* Bilder der Termine anzeigen */
showImages: true,
/* Leere Orte ausblenden */
hideEmptyLocation: true,
/*
* Farbmodus:
* "light" = helle Darstellung
* "dark" = dunkle Darstellung
* "auto" = Systemeinstellung des Browsers verwenden
*/
theme: "auto",
/*
* Methode zur automatischen Gruppierung und Farbzuordnung.
*
* "img":
* Termine mit derselben Bild-URL erhalten dieselbe Farbe.
*
* "eventPrefix":
* Der Teil des Ereignisnamens vor dem ersten Doppelpunkt wird verwendet.
*/
colorGrouping: "img",
/*
* Optional feste Farben vorgeben.
*
* matchField darf nur "ereignis" oder "img" sein.
* match enthält einen Text, der im entsprechenden JSON-Feld gesucht wird.
*
* Regeln werden von oben nach unten geprüft.
* Nicht passende Termine erhalten automatisch eine Farbe.
*/
colorRules: [
{
matchField: "ereignis",
match: "Altpapier",
color: "#d32f2f"
},
{
matchField: "ereignis",
match: "Restmüll",
color: "#546e7a"
},
{
matchField: "ereignis",
match: "Biotonne",
color: "#2e7d32"
},
{
matchField: "ereignis",
match: "gelber Sack",
color: "#f9a825"
},
{
matchField: "ereignis",
match: "Geburtstag",
color: "#8e24aa"
}
],
/*
* Automatisch verwendete Farben.
* Die Farben sollten einen ausreichenden Kontrast zu weißer Schrift haben.
*/
autoColors: [
"#1565c0",
"#2e7d32",
"#c62828",
"#6a1b9a",
"#00838f",
"#ef6c00",
"#4527a0",
"#ad1457",
"#00695c",
"#283593",
"#558b2f",
"#5d4037"
],
/*
* Optionale Oberflächenfarben.
* Ein leerer Wert verwendet die Farbe des jeweiligen Themes.
*/
colors: {
light: {
background: "",
surface: "",
surfaceSecondary: "",
text: "",
textSecondary: "",
border: "",
today: "",
weekend: ""
},
dark: {
background: "",
surface: "",
surfaceSecondary: "",
text: "",
textSecondary: "",
border: "",
today: "",
weekend: ""
}
},
/* Beschriftungen */
labels: {
today: "Heute",
month: "Monat",
week: "Woche",
day: "Tag",
previous: "Zurück",
next: "Weiter",
allDay: "Ganztägig",
noEvents: "Keine Termine",
location: "Ort",
calendarWeekShort: "KW",
invalidDate: "Ungültiges Datum"
}
};
/* ==========================================================================
DATEN GLOBAL FÜR DIESES WIDGET BEREITSTELLEN
========================================================================== */
const calendarGlobalStoreName = "__visJsontemplateCalendarWidgetStore";
if (typeof window !== "undefined") {
if (
!window[calendarGlobalStoreName] ||
typeof window[calendarGlobalStoreName] !== "object"
) {
window[calendarGlobalStoreName] = Object.create(null);
}
/*
* Einen bereits vorhandenen Zustand nicht überschreiben.
* Bei einer Datenaktualisierung wird das Template komplett neu erzeugt,
* die globale window-Variable bleibt jedoch erhalten.
*/
const existingWidgetStore =
window[calendarGlobalStoreName][widgetid] &&
typeof window[calendarGlobalStoreName][widgetid] === "object"
? window[calendarGlobalStoreName][widgetid]
: {};
window[calendarGlobalStoreName][widgetid] = {
data: data,
config: calendarConfig,
state:
existingWidgetStore.state &&
typeof existingWidgetStore.state === "object"
? existingWidgetStore.state
: {
view: calendarConfig.defaultView,
currentDate: null
}
};
}
%>
<style>
#<%- widgetid %>
{
--jscal-background-light: <%- calendarConfig.colors.light.background || "#f4f6f8" %>;
--jscal-surface-light: <%- calendarConfig.colors.light.surface || "#ffffff" %>;
--jscal-surface-secondary-light: <%- calendarConfig.colors.light.surfaceSecondary || "#f7f8fa" %>;
--jscal-text-light: <%- calendarConfig.colors.light.text || "#17212b" %>;
--jscal-text-secondary-light: <%- calendarConfig.colors.light.textSecondary || "#66717d" %>;
--jscal-border-light: <%- calendarConfig.colors.light.border || "#d7dce2" %>;
--jscal-today-light: <%- calendarConfig.colors.light.today || "#e3f2fd" %>;
--jscal-weekend-light: <%- calendarConfig.colors.light.weekend || "#faf4f4" %>;
--jscal-background-dark: <%- calendarConfig.colors.dark.background || "#12171d" %>;
--jscal-surface-dark: <%- calendarConfig.colors.dark.surface || "#1c232b" %>;
--jscal-surface-secondary-dark: <%- calendarConfig.colors.dark.surfaceSecondary || "#252e38" %>;
--jscal-text-dark: <%- calendarConfig.colors.dark.text || "#f1f4f7" %>;
--jscal-text-secondary-dark: <%- calendarConfig.colors.dark.textSecondary || "#aeb8c2" %>;
--jscal-border-dark: <%- calendarConfig.colors.dark.border || "#3b4652" %>;
--jscal-today-dark: <%- calendarConfig.colors.dark.today || "#173b55" %>;
--jscal-weekend-dark: <%- calendarConfig.colors.dark.weekend || "#271f24" %>;
--jscal-background: var(--jscal-background-light);
--jscal-surface: var(--jscal-surface-light);
--jscal-surface-secondary: var(--jscal-surface-secondary-light);
--jscal-text: var(--jscal-text-light);
--jscal-text-secondary: var(--jscal-text-secondary-light);
--jscal-border: var(--jscal-border-light);
--jscal-today: var(--jscal-today-light);
--jscal-weekend: var(--jscal-weekend-light);
box-sizing: border-box;
width: 100%;
height: 100%;
min-height: 320px;
overflow: hidden;
color: var(--jscal-text);
background: var(--jscal-background);
font-family: Arial, Helvetica, sans-serif;
}
#<%- widgetid %> *,
#<%- widgetid %> *::before,
#<%- widgetid %> *::after
{
box-sizing: border-box;
}
#<%- widgetid %>[data-jscal-theme="dark"]
{
--jscal-background: var(--jscal-background-dark);
--jscal-surface: var(--jscal-surface-dark);
--jscal-surface-secondary: var(--jscal-surface-secondary-dark);
--jscal-text: var(--jscal-text-dark);
--jscal-text-secondary: var(--jscal-text-secondary-dark);
--jscal-border: var(--jscal-border-dark);
--jscal-today: var(--jscal-today-dark);
--jscal-weekend: var(--jscal-weekend-dark);
}
@media (prefers-color-scheme: dark)
{
#<%- widgetid %>[data-jscal-theme="auto"]
{
--jscal-background: var(--jscal-background-dark);
--jscal-surface: var(--jscal-surface-dark);
--jscal-surface-secondary: var(--jscal-surface-secondary-dark);
--jscal-text: var(--jscal-text-dark);
--jscal-text-secondary: var(--jscal-text-secondary-dark);
--jscal-border: var(--jscal-border-dark);
--jscal-today: var(--jscal-today-dark);
--jscal-weekend: var(--jscal-weekend-dark);
}
}
#<%- widgetid %> .jscal
{
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 320px;
background: var(--jscal-background);
}
#<%- widgetid %> .jscal-toolbar
{
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 8px;
min-height: 58px;
padding: 8px 10px;
border-bottom: 1px solid var(--jscal-border);
background: var(--jscal-surface);
}
#<%- widgetid %> .jscal-navigation,
#<%- widgetid %> .jscal-view-buttons
{
display: flex;
align-items: center;
gap: 4px;
}
#<%- widgetid %> .jscal-view-buttons
{
margin-left: auto;
}
#<%- widgetid %> .jscal-title
{
min-width: 0;
margin: 0 8px;
overflow: hidden;
color: var(--jscal-text);
font-size: 18px;
font-weight: 700;
line-height: 1.2;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
#<%- widgetid %> .jscal-button
{
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 36px;
height: 36px;
padding: 0 10px;
border: 1px solid var(--jscal-border);
border-radius: 7px;
color: var(--jscal-text);
background: var(--jscal-surface-secondary);
font: inherit;
font-size: 13px;
font-weight: 600;
line-height: 1;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
#<%- widgetid %> .jscal-button:hover
{
filter: brightness(0.96);
}
#<%- widgetid %> .jscal-button:focus-visible
{
outline: 2px solid #1976d2;
outline-offset: 2px;
}
#<%- widgetid %> .jscal-button.is-active
{
border-color: #1976d2;
color: #ffffff;
background: #1976d2;
}
#<%- widgetid %> .jscal-button-icon
{
width: 36px;
padding: 0;
}
#<%- widgetid %> .jscal-button svg
{
width: 20px;
height: 20px;
fill: currentColor;
}
#<%- widgetid %> .jscal-content
{
position: relative;
flex: 1 1 auto;
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
background: var(--jscal-background);
}
#<%- widgetid %> .jscal-month
{
display: grid;
min-width: 640px;
grid-template-columns: auto repeat(7, minmax(80px, 1fr));
background: var(--jscal-border);
gap: 1px;
}
#<%- widgetid %> .jscal-month.no-week-numbers
{
grid-template-columns: repeat(7, minmax(80px, 1fr));
}
#<%- widgetid %> .jscal-week-header
{
position: sticky;
top: 0;
z-index: 4;
display: flex;
align-items: center;
justify-content: center;
min-height: 36px;
padding: 5px;
color: var(--jscal-text-secondary);
background: var(--jscal-surface-secondary);
font-size: 12px;
font-weight: 700;
text-align: center;
}
#<%- widgetid %> .jscal-week-number
{
display: flex;
align-items: center;
justify-content: center;
min-height: 112px;
padding: 4px;
color: var(--jscal-text-secondary);
background: var(--jscal-surface-secondary);
font-size: 11px;
font-weight: 700;
}
#<%- widgetid %> .jscal-month-day
{
position: relative;
min-height: 112px;
padding: 5px;
overflow: hidden;
background: var(--jscal-surface);
}
#<%- widgetid %> .jscal-month-day.is-outside
{
opacity: 0.52;
}
#<%- widgetid %> .jscal-month-day.is-today
{
background: var(--jscal-today);
}
#<%- widgetid %> .jscal-month-day.is-weekend
{
background: var(--jscal-weekend);
}
#<%- widgetid %> .jscal-month-day.is-weekend.is-today
{
background: var(--jscal-today);
}
#<%- widgetid %> .jscal-day-number
{
display: flex;
align-items: center;
justify-content: flex-end;
height: 22px;
margin-bottom: 3px;
color: var(--jscal-text);
font-size: 12px;
font-weight: 700;
}
#<%- widgetid %> .jscal-day-number span
{
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
padding: 0 4px;
border-radius: 11px;
}
#<%- widgetid %> .jscal-month-day.is-today .jscal-day-number span
{
color: #ffffff;
background: #1976d2;
}
#<%- widgetid %> .jscal-events
{
display: flex;
flex-direction: column;
gap: 3px;
}
#<%- widgetid %> .jscal-event
{
position: relative;
display: flex;
align-items: center;
gap: 5px;
min-width: 0;
min-height: 24px;
padding: 3px 6px;
border: 0;
border-left: 4px solid var(--jscal-event-color);
border-radius: 4px;
color: #ffffff;
background: var(--jscal-event-color);
box-shadow: inset 0 0 0 999px rgba(0, 0, 0, 0.07);
font: inherit;
font-size: 11px;
line-height: 1.25;
text-align: left;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
#<%- widgetid %> .jscal-event:hover,
#<%- widgetid %> .jscal-event:focus-visible,
#<%- widgetid %> .jscal-event.is-selected
{
filter: brightness(1.12);
outline: none;
}
#<%- widgetid %> .jscal-event-image
{
flex: 0 0 auto;
width: 17px;
height: 17px;
border-radius: 3px;
object-fit: contain;
background: rgba(255, 255, 255, 0.82);
}
#<%- widgetid %> .jscal-event-time
{
flex: 0 0 auto;
font-weight: 700;
white-space: nowrap;
}
#<%- widgetid %> .jscal-event-title
{
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#<%- widgetid %> .jscal-more
{
padding: 2px 5px;
color: var(--jscal-text-secondary);
font-size: 11px;
font-weight: 700;
}
#<%- widgetid %> .jscal-week
{
display: grid;
min-width: 720px;
min-height: 100%;
grid-template-columns: repeat(7, minmax(100px, 1fr));
gap: 1px;
background: var(--jscal-border);
}
#<%- widgetid %> .jscal-week-day
{
display: flex;
flex-direction: column;
min-height: 100%;
overflow: hidden;
background: var(--jscal-surface);
}
#<%- widgetid %> .jscal-week-day.is-weekend
{
background: var(--jscal-weekend);
}
#<%- widgetid %> .jscal-week-day.is-today
{
background: var(--jscal-today);
}
#<%- widgetid %> .jscal-week-day-header
{
position: sticky;
top: 0;
z-index: 3;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
min-height: 55px;
padding: 7px 4px;
border-bottom: 1px solid var(--jscal-border);
background: inherit;
}
#<%- widgetid %> .jscal-week-day-name
{
color: var(--jscal-text-secondary);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
#<%- widgetid %> .jscal-week-day-date
{
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 30px;
height: 30px;
padding: 0 5px;
border-radius: 15px;
color: var(--jscal-text);
font-size: 16px;
font-weight: 700;
}
#<%- widgetid %> .jscal-week-day.is-today .jscal-week-day-date
{
color: #ffffff;
background: #1976d2;
}
#<%- widgetid %> .jscal-week-events
{
flex: 1;
overflow: hidden;
}
#<%- widgetid %> .jscal-week .jscal-event
{
display: flex;
align-items: flex-start;
gap: 6px;
min-height: 36px;
max-height: 54px;
padding: 6px;
overflow: hidden;
}
#<%- widgetid %> .jscal-week .jscal-event-title
{
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-height: 1.2;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
#<%- widgetid %> .jscal-empty
{
padding: 14px 6px;
color: var(--jscal-text-secondary);
font-size: 12px;
text-align: center;
}
#<%- widgetid %> .jscal-day-view
{
min-height: 100%;
padding: 12px;
}
#<%- widgetid %> .jscal-day-section
{
margin-bottom: 16px;
}
#<%- widgetid %> .jscal-day-section-title
{
margin: 0 0 7px;
padding: 0 2px 6px;
border-bottom: 1px solid var(--jscal-border);
color: var(--jscal-text-secondary);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
#<%- widgetid %> .jscal-day-event
{
display: grid;
grid-template-columns: 70px minmax(0, 1fr);
margin-bottom: 8px;
border: 1px solid var(--jscal-border);
border-left: 5px solid var(--jscal-event-color);
border-radius: 7px;
overflow: hidden;
background: var(--jscal-surface);
}
#<%- widgetid %> .jscal-day-event-time
{
display: flex;
align-items: flex-start;
justify-content: center;
padding: 11px 6px;
color: var(--jscal-text-secondary);
background: var(--jscal-surface-secondary);
font-size: 12px;
font-weight: 700;
text-align: center;
}
#<%- widgetid %> .jscal-day-event-content
{
display: flex;
align-items: flex-start;
gap: 9px;
min-width: 0;
padding: 9px;
border: 0;
color: var(--jscal-text);
background: transparent;
font: inherit;
text-align: left;
cursor: pointer;
}
#<%- widgetid %> .jscal-day-event-content:hover,
#<%- widgetid %> .jscal-day-event-content:focus-visible
{
background: var(--jscal-surface-secondary);
outline: none;
}
#<%- widgetid %> .jscal-day-event-image
{
flex: 0 0 auto;
width: 34px;
height: 34px;
border-radius: 5px;
object-fit: contain;
}
#<%- widgetid %> .jscal-day-event-text
{
min-width: 0;
}
#<%- widgetid %> .jscal-day-event-title
{
color: var(--jscal-text);
font-size: 14px;
font-weight: 700;
line-height: 1.35;
}
#<%- widgetid %> .jscal-day-event-location
{
margin-top: 4px;
color: var(--jscal-text-secondary);
font-size: 12px;
}
#<%- widgetid %> .jscal-tooltip
{
position: fixed;
z-index: 2147483000;
display: none;
width: min(320px, calc(100vw - 20px));
max-height: min(360px, calc(100vh - 20px));
padding: 12px;
overflow: auto;
border: 1px solid var(--jscal-border);
border-radius: 9px;
color: var(--jscal-text);
background: var(--jscal-surface);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.28);
pointer-events: none;
}
#<%- widgetid %> .jscal-tooltip.is-visible
{
display: block;
}
#<%- widgetid %> .jscal-tooltip-header
{
display: flex;
align-items: flex-start;
gap: 9px;
}
#<%- widgetid %> .jscal-tooltip-image
{
flex: 0 0 auto;
width: 42px;
height: 42px;
border-radius: 6px;
object-fit: contain;
}
#<%- widgetid %> .jscal-tooltip-title
{
min-width: 0;
color: var(--jscal-text);
font-size: 14px;
font-weight: 700;
line-height: 1.35;
overflow-wrap: anywhere;
}
#<%- widgetid %> .jscal-tooltip-row
{
display: grid;
grid-template-columns: 62px minmax(0, 1fr);
gap: 8px;
margin-top: 10px;
color: var(--jscal-text);
font-size: 12px;
line-height: 1.4;
}
#<%- widgetid %> .jscal-tooltip-label
{
color: var(--jscal-text-secondary);
font-weight: 700;
}
#<%- widgetid %> .jscal-tooltip-value
{
overflow-wrap: anywhere;
}
#<%- widgetid %> .jscal-invalid
{
margin: 6px;
padding: 7px;
border: 1px solid var(--jscal-border);
border-left: 4px solid #c62828;
border-radius: 5px;
color: var(--jscal-text);
background: var(--jscal-surface);
font-size: 12px;
}
@media (max-width: 700px)
{
#<%- widgetid %> .jscal-toolbar
{
flex-wrap: wrap;
gap: 6px;
}
#<%- widgetid %> .jscal-title
{
order: -1;
flex: 1 0 100%;
width: 100%;
margin: 0;
padding: 3px;
font-size: 16px;
}
#<%- widgetid %> .jscal-view-buttons
{
margin-left: auto;
}
#<%- widgetid %> .jscal-button
{
min-width: 34px;
height: 34px;
padding: 0 8px;
font-size: 12px;
}
#<%- widgetid %> .jscal-month-day
{
min-height: 96px;
}
}
</style>
<div class="jscal" data-jscal-container>
<div class="jscal-toolbar">
<div class="jscal-navigation">
<button
type="button"
class="jscal-button jscal-button-icon"
data-jscal-action="previous"
aria-label="<%- calendarConfig.labels.previous %>"
title="<%- calendarConfig.labels.previous %>"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M15.4 5.4 8.8 12l6.6 6.6-1.4 1.4L6 12l8-8z"></path>
</svg>
</button>
<button
type="button"
class="jscal-button"
data-jscal-action="today"
>
<%- calendarConfig.labels.today %>
</button>
<button
type="button"
class="jscal-button jscal-button-icon"
data-jscal-action="next"
aria-label="<%- calendarConfig.labels.next %>"
title="<%- calendarConfig.labels.next %>"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M8.6 18.6 15.2 12 8.6 5.4 10 4l8 8-8 8z"></path>
</svg>
</button>
</div>
<div class="jscal-title" data-jscal-title></div>
<div class="jscal-view-buttons">
<button
type="button"
class="jscal-button"
data-jscal-view="month"
>
<%- calendarConfig.labels.month %>
</button>
<button
type="button"
class="jscal-button"
data-jscal-view="week"
>
<%- calendarConfig.labels.week %>
</button>
<button
type="button"
class="jscal-button"
data-jscal-view="day"
>
<%- calendarConfig.labels.day %>
</button>
</div>
</div>
<div class="jscal-content" data-jscal-content></div>
<div
class="jscal-tooltip"
data-jscal-tooltip
role="tooltip"
></div>
</div>
<script>
(function ()
{
"use strict";
const widgetId = "<%- widgetid %>";
const globalStoreName = "__visJsontemplateCalendarWidgetStore";
const widgetStore =
window[globalStoreName] &&
window[globalStoreName][widgetId]
? window[globalStoreName][widgetId]
: null;
if (!widgetStore)
{
console.warn(
"JSON-Template-Kalender: Keine globalen Daten für Widget gefunden:",
widgetId
);
return;
}
const rawData = widgetStore.data;
const config = widgetStore.config;
const root = document.getElementById(widgetId);
if (!root)
{
return;
}
if (typeof root.__jsonCalendarCleanup === "function")
{
root.__jsonCalendarCleanup();
}
root.setAttribute("data-jscal-theme", config.theme || "auto");
const container = root.querySelector("[data-jscal-container]");
const contentElement = root.querySelector("[data-jscal-content]");
const titleElement = root.querySelector("[data-jscal-title]");
const tooltipElement = root.querySelector("[data-jscal-tooltip]");
if (!container || !contentElement || !titleElement || !tooltipElement)
{
return;
}
const storedState =
widgetStore.state &&
typeof widgetStore.state === "object"
? widgetStore.state
: {};
const storedView = ["month", "week", "day"].includes(storedState.view)
? storedState.view
: null;
const storedCurrentDate =
typeof storedState.currentDate === "string"
? parseStoredDate(storedState.currentDate)
: null;
const state = {
view:
storedView ||
(
["month", "week", "day"].includes(config.defaultView)
? config.defaultView
: "month"
),
currentDate:
storedCurrentDate ||
startOfDay(new Date()),
selectedEventElement: null,
tooltipEventId: null
};
const formatterCache = {};
const groupColorMap = new Map();
const events = Array.isArray(rawData)
? rawData
.map(function (item, index)
{
return normalizeEvent(item, index);
})
.filter(function (item)
{
return item !== null;
})
: [];
const invalidItems = Array.isArray(rawData)
? rawData
.map(function (item, index)
{
return {
item: item,
index: index,
parsed: normalizeEvent(item, index)
};
})
.filter(function (entry)
{
return entry.parsed === null;
})
: [];
function pad(value)
{
return String(value).padStart(2, "0");
}
function escapeHtml(value)
{
return String(value === undefined || value === null ? "" : value)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function escapeAttribute(value)
{
return escapeHtml(value);
}
function createLocalDate(year, month, day, hour, minute)
{
const date = new Date(
year,
month - 1,
day,
hour || 0,
minute || 0,
0,
0
);
if (
date.getFullYear() !== year ||
date.getMonth() !== month - 1 ||
date.getDate() !== day
)
{
return null;
}
return date;
}
function parseStoredDate(value)
{
if (typeof value !== "string")
{
return null;
}
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match)
{
return null;
}
return createLocalDate(
Number(match[1]),
Number(match[2]),
Number(match[3]),
0,
0
);
}
function parseDateTimeText(value)
{
if (typeof value !== "string")
{
return null;
}
const normalized = value
.trim()
.replace(/\s+/g, " ");
const separatorMatch = normalized.match(/\s+-\s+/);
if (!separatorMatch)
{
return null;
}
const separatorIndex = separatorMatch.index;
const separatorLength = separatorMatch[0].length;
const startText = normalized.slice(0, separatorIndex).trim();
const endText = normalized
.slice(separatorIndex + separatorLength)
.trim();
const fullDatePattern = /^(\d{1,2})\.(\d{1,2})\.(\d{4})(?:\s+(\d{1,2}):(\d{2}))?$/;
const timeOnlyPattern = /^(\d{1,2}):(\d{2})$/;
const startMatch = startText.match(fullDatePattern);
if (!startMatch)
{
return null;
}
const startDay = Number(startMatch[1]);
const startMonth = Number(startMatch[2]);
const startYear = Number(startMatch[3]);
const startHasTime = startMatch[4] !== undefined;
const startHour = startHasTime ? Number(startMatch[4]) : 0;
const startMinute = startHasTime ? Number(startMatch[5]) : 0;
if (
startHour < 0 ||
startHour > 23 ||
startMinute < 0 ||
startMinute > 59
)
{
return null;
}
const start = createLocalDate(
startYear,
startMonth,
startDay,
startHour,
startMinute
);
if (!start)
{
return null;
}
let end = null;
let endHasTime = false;
const endFullDateMatch = endText.match(fullDatePattern);
const endTimeOnlyMatch = endText.match(timeOnlyPattern);
if (endFullDateMatch)
{
const endDay = Number(endFullDateMatch[1]);
const endMonth = Number(endFullDateMatch[2]);
const endYear = Number(endFullDateMatch[3]);
endHasTime = endFullDateMatch[4] !== undefined;
const endHour = endHasTime ? Number(endFullDateMatch[4]) : 0;
const endMinute = endHasTime ? Number(endFullDateMatch[5]) : 0;
if (
endHour < 0 ||
endHour > 23 ||
endMinute < 0 ||
endMinute > 59
)
{
return null;
}
end = createLocalDate(
endYear,
endMonth,
endDay,
endHour,
endMinute
);
} else if (endTimeOnlyMatch)
{
endHasTime = true;
const endHour = Number(endTimeOnlyMatch[1]);
const endMinute = Number(endTimeOnlyMatch[2]);
if (
endHour < 0 ||
endHour > 23 ||
endMinute < 0 ||
endMinute > 59
)
{
return null;
}
end = createLocalDate(
startYear,
startMonth,
startDay,
endHour,
endMinute
);
}
if (!end)
{
return null;
}
const allDay = !startHasTime && !endHasTime;
if (allDay)
{
end.setHours(23, 59, 59, 999);
}
if (end.getTime() < start.getTime())
{
return null;
}
return {
start: start,
end: end,
allDay: allDay,
sourceText: normalized
};
}
function normalizeEvent(item, index)
{
if (!item || typeof item !== "object")
{
return null;
}
const parsedDate = parseDateTimeText(item.zeit);
if (!parsedDate)
{
return null;
}
const event = {
id: String(index),
img: typeof item.img === "string"
? item.img.trim()
: "",
zeit: typeof item.zeit === "string"
? item.zeit.trim()
: "",
ereignis: typeof item.ereignis === "string"
? item.ereignis.trim()
: "",
ort: typeof item.ort === "string"
? item.ort.trim()
: "",
start: parsedDate.start,
end: parsedDate.end,
allDay: parsedDate.allDay
};
event.color = getEventColor(event);
return event;
}
function getGroupingKey(event)
{
if (config.colorGrouping === "eventPrefix")
{
const eventText = event.ereignis || "";
const separatorPosition = eventText.indexOf(":");
if (separatorPosition >= 0)
{
return eventText.slice(0, separatorPosition).trim().toLowerCase();
}
return eventText.trim().toLowerCase();
}
return (event.img || event.ereignis || event.id).toLowerCase();
}
function getEventColor(event)
{
if (Array.isArray(config.colorRules))
{
for (const rule of config.colorRules)
{
if (
!rule ||
!["ereignis", "img"].includes(rule.matchField) ||
typeof rule.match !== "string" ||
typeof rule.color !== "string"
)
{
continue;
}
const fieldValue = String(event[rule.matchField] || "");
if (
fieldValue
.toLocaleLowerCase(config.locale)
.includes(rule.match.toLocaleLowerCase(config.locale))
)
{
return rule.color;
}
}
}
const groupKey = getGroupingKey(event);
if (groupColorMap.has(groupKey))
{
return groupColorMap.get(groupKey);
}
const palette = Array.isArray(config.autoColors) && config.autoColors.length > 0
? config.autoColors
: ["#1565c0"];
const color = palette[groupColorMap.size % palette.length];
groupColorMap.set(groupKey, color);
return color;
}
function startOfDay(date)
{
return new Date(
date.getFullYear(),
date.getMonth(),
date.getDate(),
0,
0,
0,
0
);
}
function endOfDay(date)
{
return new Date(
date.getFullYear(),
date.getMonth(),
date.getDate(),
23,
59,
59,
999
);
}
function addDays(date, amount)
{
const result = new Date(date);
result.setDate(result.getDate() + amount);
return result;
}
function addMonths(date, amount)
{
const targetDay = date.getDate();
const result = new Date(
date.getFullYear(),
date.getMonth() + amount,
1
);
const lastDay = new Date(
result.getFullYear(),
result.getMonth() + 1,
0
).getDate();
result.setDate(Math.min(targetDay, lastDay));
return result;
}
function startOfWeek(date)
{
const result = startOfDay(date);
const currentWeekday = result.getDay();
const firstDay = Number(config.firstDayOfWeek) === 0 ? 0 : 1;
const difference = (currentWeekday - firstDay + 7) % 7;
result.setDate(result.getDate() - difference);
return result;
}
function sameDay(first, second)
{
return (
first.getFullYear() === second.getFullYear() &&
first.getMonth() === second.getMonth() &&
first.getDate() === second.getDate()
);
}
function dateKey(date)
{
return [
date.getFullYear(),
pad(date.getMonth() + 1),
pad(date.getDate())
].join("-");
}
function saveState()
{
if (
!window[globalStoreName] ||
!window[globalStoreName][widgetId]
)
{
return;
}
window[globalStoreName][widgetId].state = {
view: state.view,
currentDate: dateKey(state.currentDate)
};
}
function getFormatter(options)
{
const key = JSON.stringify(options);
if (!formatterCache[key])
{
formatterCache[key] = new Intl.DateTimeFormat(
config.locale || "de-DE",
options
);
}
return formatterCache[key];
}
function formatDate(date, options)
{
return getFormatter(options).format(date);
}
function formatTime(date)
{
return formatDate(date, {
hour: "2-digit",
minute: "2-digit"
});
}
function formatEventTime(event)
{
if (event.allDay)
{
if (sameDay(event.start, event.end))
{
return config.labels.allDay;
}
return [
formatDate(event.start, {
day: "2-digit",
month: "2-digit",
year: "numeric"
}),
"–",
formatDate(event.end, {
day: "2-digit",
month: "2-digit",
year: "numeric"
})
].join(" ");
}
if (sameDay(event.start, event.end))
{
return formatTime(event.start) + "–" + formatTime(event.end);
}
return [
formatDate(event.start, {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit"
}),
"–",
formatDate(event.end, {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit"
})
].join(" ");
}
function getIsoWeekNumber(date)
{
const target = new Date(
Date.UTC(
date.getFullYear(),
date.getMonth(),
date.getDate()
)
);
const weekday = target.getUTCDay() || 7;
target.setUTCDate(target.getUTCDate() + 4 - weekday);
const yearStart = new Date(
Date.UTC(target.getUTCFullYear(), 0, 1)
);
return Math.ceil(
(((target - yearStart) / 86400000) + 1) / 7
);
}
function eventOccursOnDay(event, day)
{
const dayStart = startOfDay(day);
const dayEnd = endOfDay(day);
return (
event.start.getTime() <= dayEnd.getTime() &&
event.end.getTime() >= dayStart.getTime()
);
}
function getEventsForDay(day)
{
return events
.filter(function (event)
{
return eventOccursOnDay(event, day);
})
.sort(function (first, second)
{
if (first.allDay !== second.allDay)
{
return first.allDay ? -1 : 1;
}
const timeDifference =
first.start.getTime() - second.start.getTime();
if (timeDifference !== 0)
{
return timeDifference;
}
return first.ereignis.localeCompare(
second.ereignis,
config.locale || "de-DE"
);
});
}
function isWeekend(date)
{
return date.getDay() === 0 || date.getDay() === 6;
}
function renderSmallEvent(event, options)
{
const settings = options || {};
const showTime = settings.showTime !== false;
const imageHtml =
config.showImages &&
event.img
? '<img class="jscal-event-image" src="' +
escapeAttribute(event.img) +
'" alt="">'
: "";
let timeHtml = "";
if (showTime)
{
timeHtml = '<span class="jscal-event-time">' +
escapeHtml(
event.allDay
? ""
: formatTime(event.start)
) +
"</span>";
}
return [
'<button type="button"',
' class="jscal-event"',
' data-jscal-event-id="',
escapeAttribute(event.id),
'"',
' style="--jscal-event-color:',
escapeAttribute(event.color),
';"',
' aria-label="',
escapeAttribute(event.ereignis),
'">',
imageHtml,
timeHtml,
'<span class="jscal-event-title">',
escapeHtml(event.ereignis || ""),
"</span>",
"</button>"
].join("");
}
function renderMonth()
{
const monthStart = new Date(
state.currentDate.getFullYear(),
state.currentDate.getMonth(),
1
);
const monthEnd = new Date(
state.currentDate.getFullYear(),
state.currentDate.getMonth() + 1,
0
);
const gridStart = startOfWeek(monthStart);
let gridEnd = addDays(startOfWeek(monthEnd), 6);
if (gridEnd.getTime() < monthEnd.getTime())
{
gridEnd = addDays(gridEnd, 7);
}
const dayHeaders = [];
const headerReference = startOfWeek(new Date(2026, 0, 5));
if (config.showWeekNumbers)
{
dayHeaders.push(
'<div class="jscal-week-header">' +
escapeHtml(config.labels.calendarWeekShort) +
"</div>"
);
}
for (let index = 0; index < 7; index++)
{
const headerDate = addDays(headerReference, index);
dayHeaders.push(
'<div class="jscal-week-header">' +
escapeHtml(
formatDate(headerDate, {
weekday: "short"
})
) +
"</div>"
);
}
const cells = [];
let cursor = new Date(gridStart);
let rowIndex = 0;
while (cursor.getTime() <= gridEnd.getTime())
{
if (config.showWeekNumbers && rowIndex % 7 === 0)
{
cells.push(
'<div class="jscal-week-number">' +
escapeHtml(
config.labels.calendarWeekShort +
" " +
getIsoWeekNumber(cursor)
) +
"</div>"
);
}
const currentDay = new Date(cursor);
const currentEvents = getEventsForDay(currentDay);
const visibleEvents = currentEvents.slice(0, 4);
const remainingCount = currentEvents.length - visibleEvents.length;
const classes = ["jscal-month-day"];
if (currentDay.getMonth() !== state.currentDate.getMonth())
{
classes.push("is-outside");
}
if (sameDay(currentDay, new Date()))
{
classes.push("is-today");
}
if (config.highlightWeekend && isWeekend(currentDay))
{
classes.push("is-weekend");
}
let eventHtml = visibleEvents
.map(function (event)
{
return renderSmallEvent(event, {
showTime: true
});
})
.join("");
if (remainingCount > 0)
{
eventHtml +=
'<div class="jscal-more">+' +
escapeHtml(remainingCount) +
"</div>";
}
cells.push(
'<div class="' +
classes.join(" ") +
'" data-jscal-date="' +
escapeAttribute(dateKey(currentDay)) +
'">' +
'<div class="jscal-day-number"><span>' +
escapeHtml(currentDay.getDate()) +
"</span></div>" +
'<div class="jscal-events">' +
eventHtml +
"</div>" +
"</div>"
);
cursor = addDays(cursor, 1);
rowIndex++;
}
const className = config.showWeekNumbers
? "jscal-month"
: "jscal-month no-week-numbers";
titleElement.textContent = formatDate(state.currentDate, {
month: "long",
year: "numeric"
});
contentElement.innerHTML =
'<div class="' +
className +
'">' +
dayHeaders.join("") +
cells.join("") +
"</div>";
}
function renderWeek()
{
const weekStart = startOfWeek(state.currentDate);
const weekEnd = addDays(weekStart, 6);
const columns = [];
for (let index = 0; index < 7; index++)
{
const day = addDays(weekStart, index);
const dayEvents = getEventsForDay(day);
const classes = ["jscal-week-day"];
if (sameDay(day, new Date()))
{
classes.push("is-today");
}
if (config.highlightWeekend && isWeekend(day))
{
classes.push("is-weekend");
}
let eventsHtml = "";
if (dayEvents.length === 0)
{
eventsHtml =
'<div class="jscal-empty">' +
escapeHtml(config.labels.noEvents) +
"</div>";
} else
{
eventsHtml = dayEvents
.map(function (event)
{
return renderSmallEvent(event, {
showTime: true
});
})
.join("");
}
columns.push(
'<section class="' +
classes.join(" ") +
'" data-jscal-date="' +
escapeAttribute(dateKey(day)) +
'">' +
'<div class="jscal-week-day-header">' +
'<div class="jscal-week-day-name">' +
escapeHtml(
formatDate(day, {
weekday: "short"
})
) +
"</div>" +
'<div class="jscal-week-day-date">' +
escapeHtml(day.getDate()) +
"</div>" +
"</div>" +
'<div class="jscal-week-events">' +
eventsHtml +
"</div>" +
"</section>"
);
}
const sameMonth =
weekStart.getMonth() === weekEnd.getMonth() &&
weekStart.getFullYear() === weekEnd.getFullYear();
if (sameMonth)
{
titleElement.textContent =
config.labels.calendarWeekShort +
" " +
getIsoWeekNumber(weekStart) +
" · " +
formatDate(weekStart, {
month: "long",
year: "numeric"
});
} else
{
titleElement.textContent =
config.labels.calendarWeekShort +
" " +
getIsoWeekNumber(weekStart) +
" · " +
formatDate(weekStart, {
day: "2-digit",
month: "2-digit"
}) +
" – " +
formatDate(weekEnd, {
day: "2-digit",
month: "2-digit",
year: "numeric"
});
}
contentElement.innerHTML =
'<div class="jscal-week">' +
columns.join("") +
"</div>";
}
function renderDayEvent(event)
{
const imageHtml =
config.showImages &&
event.img
? '<img class="jscal-day-event-image" src="' +
escapeAttribute(event.img) +
'" alt="">'
: "";
const locationHtml =
event.ort &&
!(config.hideEmptyLocation && event.ort.length === 0)
? '<div class="jscal-day-event-location">' +
escapeHtml(event.ort) +
"</div>"
: "";
return [
'<article class="jscal-day-event"',
' style="--jscal-event-color:',
escapeAttribute(event.color),
';">',
'<div class="jscal-day-event-time">',
escapeHtml(
event.allDay
? config.labels.allDay
: formatTime(event.start)
),
"</div>",
'<button type="button"',
' class="jscal-day-event-content"',
' data-jscal-event-id="',
escapeAttribute(event.id),
'"',
' aria-label="',
escapeAttribute(event.ereignis),
'">',
imageHtml,
'<div class="jscal-day-event-text">',
'<div class="jscal-day-event-title">',
escapeHtml(event.ereignis),
"</div>",
locationHtml,
"</div>",
"</button>",
"</article>"
].join("");
}
function renderDay()
{
const dayEvents = getEventsForDay(state.currentDate);
const allDayEvents = dayEvents.filter(function (event)
{
return event.allDay;
});
const timedEvents = dayEvents.filter(function (event)
{
return !event.allDay;
});
const sections = [];
if (allDayEvents.length > 0)
{
sections.push(
'<section class="jscal-day-section">' +
'<h3 class="jscal-day-section-title">' +
escapeHtml(config.labels.allDay) +
"</h3>" +
allDayEvents.map(renderDayEvent).join("") +
"</section>"
);
}
if (timedEvents.length > 0)
{
sections.push(
'<section class="jscal-day-section">' +
timedEvents.map(renderDayEvent).join("") +
"</section>"
);
}
if (sections.length === 0)
{
sections.push(
'<div class="jscal-empty">' +
escapeHtml(config.labels.noEvents) +
"</div>"
);
}
titleElement.textContent = formatDate(state.currentDate, {
weekday: "long",
day: "2-digit",
month: "long",
year: "numeric"
});
contentElement.innerHTML =
'<div class="jscal-day-view">' +
sections.join("") +
"</div>";
}
function renderInvalidItems()
{
if (invalidItems.length === 0)
{
return;
}
const invalidHtml = invalidItems
.map(function (entry)
{
const timeText =
entry.item &&
typeof entry.item.zeit === "string"
? entry.item.zeit
: "";
return (
'<div class="jscal-invalid">' +
escapeHtml(config.labels.invalidDate) +
": " +
escapeHtml(timeText) +
"</div>"
);
})
.join("");
contentElement.insertAdjacentHTML(
"beforeend",
invalidHtml
);
}
function updateViewButtons()
{
const buttons = root.querySelectorAll("[data-jscal-view]");
buttons.forEach(function (button)
{
const isActive =
button.getAttribute("data-jscal-view") === state.view;
button.classList.toggle("is-active", isActive);
button.setAttribute(
"aria-pressed",
isActive ? "true" : "false"
);
});
}
function render()
{
saveState();
hideTooltip();
if (state.view === "week")
{
renderWeek();
} else if (state.view === "day")
{
renderDay();
} else
{
renderMonth();
}
renderInvalidItems();
updateViewButtons();
}
function findEventById(eventId)
{
return events.find(function (event)
{
return event.id === String(eventId);
}) || null;
}
function buildTooltipHtml(event)
{
const imageHtml =
config.showImages &&
event.img
? '<img class="jscal-tooltip-image" src="' +
escapeAttribute(event.img) +
'" alt="">'
: "";
const locationRow =
event.ort &&
!(config.hideEmptyLocation && event.ort.length === 0)
? '<div class="jscal-tooltip-row">' +
'<div class="jscal-tooltip-label">' +
escapeHtml(config.labels.location) +
"</div>" +
'<div class="jscal-tooltip-value">' +
escapeHtml(event.ort) +
"</div>" +
"</div>"
: "";
return (
'<div class="jscal-tooltip-header">' +
imageHtml +
'<div class="jscal-tooltip-title">' +
escapeHtml(event.ereignis) +
"</div>" +
"</div>" +
'<div class="jscal-tooltip-row">' +
'<div class="jscal-tooltip-label">Zeit</div>' +
'<div class="jscal-tooltip-value">' +
escapeHtml(formatEventTime(event)) +
"</div>" +
"</div>" +
locationRow
);
}
function positionTooltip(targetElement)
{
const targetRect = targetElement.getBoundingClientRect();
const tooltipRect = tooltipElement.getBoundingClientRect();
const margin = 8;
let left = targetRect.left;
let top = targetRect.bottom + margin;
if (left + tooltipRect.width > window.innerWidth - margin)
{
left = window.innerWidth - tooltipRect.width - margin;
}
if (left < margin)
{
left = margin;
}
if (top + tooltipRect.height > window.innerHeight - margin)
{
top = targetRect.top - tooltipRect.height - margin;
}
if (top < margin)
{
top = margin;
}
tooltipElement.style.left = Math.round(left) + "px";
tooltipElement.style.top = Math.round(top) + "px";
}
function showTooltip(targetElement)
{
const eventId = targetElement.getAttribute("data-jscal-event-id");
const event = findEventById(eventId);
if (!event)
{
return;
}
if (
state.selectedEventElement &&
state.selectedEventElement !== targetElement
)
{
state.selectedEventElement.classList.remove("is-selected");
}
state.selectedEventElement = targetElement;
state.tooltipEventId = event.id;
targetElement.classList.add("is-selected");
tooltipElement.innerHTML = buildTooltipHtml(event);
tooltipElement.classList.add("is-visible");
tooltipElement.setAttribute("aria-hidden", "false");
positionTooltip(targetElement);
}
function hideTooltip()
{
if (state.selectedEventElement)
{
state.selectedEventElement.classList.remove("is-selected");
}
state.selectedEventElement = null;
state.tooltipEventId = null;
tooltipElement.classList.remove("is-visible");
tooltipElement.setAttribute("aria-hidden", "true");
tooltipElement.innerHTML = "";
}
function navigate(direction)
{
if (state.view === "month")
{
state.currentDate = addMonths(
state.currentDate,
direction
);
} else if (state.view === "week")
{
state.currentDate = addDays(
state.currentDate,
direction * 7
);
} else
{
state.currentDate = addDays(
state.currentDate,
direction
);
}
render();
}
function handleClick(event)
{
const actionButton = event.target.closest("[data-jscal-action]");
if (actionButton && root.contains(actionButton))
{
const action = actionButton.getAttribute("data-jscal-action");
if (action === "previous")
{
navigate(-1);
} else if (action === "next")
{
navigate(1);
} else if (action === "today")
{
state.currentDate = startOfDay(new Date());
render();
}
return;
}
const viewButton = event.target.closest("[data-jscal-view]");
if (viewButton && root.contains(viewButton))
{
const nextView = viewButton.getAttribute("data-jscal-view");
if (["month", "week", "day"].includes(nextView))
{
state.view = nextView;
render();
}
return;
}
const eventElement = event.target.closest("[data-jscal-event-id]");
if (eventElement && root.contains(eventElement))
{
const eventId =
eventElement.getAttribute("data-jscal-event-id");
if (
state.tooltipEventId === eventId &&
tooltipElement.classList.contains("is-visible")
)
{
hideTooltip();
} else
{
showTooltip(eventElement);
}
event.preventDefault();
event.stopPropagation();
return;
}
const dayElement = event.target.closest("[data-jscal-date]");
if (dayElement && root.contains(dayElement))
{
const dateText = dayElement.getAttribute("data-jscal-date");
const match = dateText
? dateText.match(/^(\d{4})-(\d{2})-(\d{2})$/)
: null;
if (match)
{
const selectedDate = createLocalDate(
Number(match[1]),
Number(match[2]),
Number(match[3]),
0,
0
);
if (selectedDate)
{
state.currentDate = selectedDate;
state.view = "day";
render();
}
}
}
}
function handlePointerOver(event)
{
if (
event.pointerType &&
event.pointerType !== "mouse"
)
{
return;
}
const eventElement = event.target.closest("[data-jscal-event-id]");
if (!eventElement || !root.contains(eventElement))
{
return;
}
const relatedTarget = event.relatedTarget;
if (
relatedTarget &&
eventElement.contains(relatedTarget)
)
{
return;
}
showTooltip(eventElement);
}
function handlePointerOut(event)
{
if (
event.pointerType &&
event.pointerType !== "mouse"
)
{
return;
}
const eventElement = event.target.closest("[data-jscal-event-id]");
if (!eventElement || !root.contains(eventElement))
{
return;
}
const relatedTarget = event.relatedTarget;
if (
relatedTarget &&
eventElement.contains(relatedTarget)
)
{
return;
}
hideTooltip();
}
function handleDocumentPointerDown(event)
{
if (!root.contains(event.target))
{
hideTooltip();
}
}
function handleResize()
{
if (
state.selectedEventElement &&
tooltipElement.classList.contains("is-visible")
)
{
positionTooltip(state.selectedEventElement);
}
}
root.addEventListener("click", handleClick);
root.addEventListener("pointerover", handlePointerOver);
root.addEventListener("pointerout", handlePointerOut);
document.addEventListener("pointerdown", handleDocumentPointerDown);
window.addEventListener("resize", handleResize);
root.__jsonCalendarCleanup = function ()
{
root.removeEventListener("click", handleClick);
root.removeEventListener("pointerover", handlePointerOver);
root.removeEventListener("pointerout", handlePointerOut);
document.removeEventListener(
"pointerdown",
handleDocumentPointerDown
);
window.removeEventListener("resize", handleResize);
};
render();
})();
</script>