Controls
viewof filePath = Inputs.select([
"data/events.tsv",
"data/events.csv",
"data/events-long-labels.csv",
"data/schedule.csv",
"data/workshop-week.tsv",
"data/bad-empty.tsv",
"data/bad-missing-start.csv",
"data/bad-missing-title.tsv",
"data/bad-multiple-row-errors.csv",
"data/bad-no-header.tsv"
], {
label: "file",
value: "data/events.tsv"
})
viewof fileSepRaw = Inputs.select(["\\t", ",", ";"], {
label: "file-sep",
value: filePath.toLowerCase().endsWith(".csv") ? "," : "\\t"
})
viewof initialDate = Inputs.text({
label: "date",
value: autoDate
})
viewof defaultView = Inputs.select(["month", "week", "day"], {
label: "defaultView",
value: "week"
})
viewof heightPx = Inputs.range([420, 980], {
label: "height",
value: 680,
step: 20,
format: (value) => `${value}px`
})
viewof isReadOnly = Inputs.toggle({
label: "isReadOnly",
value: true
})
viewof useDetailPopup = Inputs.toggle({
label: "useDetailPopup",
value: false
})viewof weekStartDay = Inputs.range([0, 6], {
label: "week.startDayOfWeek",
value: 1,
step: 1
})
viewof weekDayNames = Inputs.text({
label: "week.dayNames (comma separated)",
value: "Mon,Tue,Wed,Thu,Fri,Sat,Sun"
})
viewof weekNarrowWeekend = Inputs.toggle({
label: "week.narrowWeekend",
value: false
})
viewof weekWorkweek = Inputs.toggle({
label: "week.workweek",
value: false
})
viewof weekShowNowIndicator = Inputs.toggle({
label: "week.showNowIndicator",
value: true
})
viewof weekHourStart = Inputs.range([0, 23], {
label: "week.hourStart",
value: 6,
step: 1
})
viewof weekHourEnd = Inputs.range([1, 24], {
label: "week.hourEnd",
value: 22,
step: 1
})
viewof weekEventView = Inputs.checkbox(["allday", "time"], {
label: "week.eventView",
value: ["allday", "time"]
})
viewof weekTaskView = Inputs.checkbox(["milestone", "task"], {
label: "week.taskView",
value: ["milestone", "task"]
})viewof monthStartDay = Inputs.range([0, 6], {
label: "month.startDayOfWeek",
value: 1,
step: 1
})
viewof monthDayNames = Inputs.text({
label: "month.dayNames (comma separated)",
value: "Sun,Mon,Tue,Wed,Thu,Fri,Sat"
})
viewof monthNarrowWeekend = Inputs.toggle({
label: "month.narrowWeekend",
value: false
})
viewof monthWorkweek = Inputs.toggle({
label: "month.workweek",
value: false
})
viewof monthVisibleWeeksCount = Inputs.range([0, 6], {
label: "month.visibleWeeksCount (0=auto)",
value: 0,
step: 1
})
viewof monthAlways6Weeks = Inputs.toggle({
label: "month.isAlways6Weeks",
value: true
})viewToggle = {
const wk = document.querySelector(".week-options-container")
const mo = document.querySelector(".month-options-container")
if (wk) wk.style.display = (defaultView === "week") ? "block" : "none"
if (mo) mo.style.display = (defaultView === "month") ? "block" : "none"
return defaultView
}Calendar
eventDetailValue = (event, item, calendars) => {
if (item === "calendar") {
const calendar = calendars.find((candidate) => candidate.id === event.calendarId)
return calendar ? calendar.name || "" : ""
}
let value = event[item]
if (value == null && event.raw) value = event.raw[item]
if (Array.isArray(value)) return value.join(", ")
return value == null ? "" : value
}renderEventDetails = (event, items, calendars) => {
const details = items.map((item) => {
const value = eventDetailValue(event, item, calendars)
if (value === "") return ""
const iconClass = Object.prototype.hasOwnProperty.call(detailItemIcons, item) ? detailItemIcons[item] : ""
const iconStyle = item === "calendar" && event.backgroundColor ? ` style="background-color:${escapeHtml(event.backgroundColor)}"` : ""
const icon = iconClass ? `<span class="toastui-calendar-icon toastui-calendar-event-detail-icon ${iconClass}"${iconStyle}></span>` : ""
const label = nativeDetailItems.has(item) ? "" : `<strong>${escapeHtml(item)}:</strong> `
return `<span class="toastui-calendar-event-detail-item">${icon}${label}${escapeHtml(value)}</span>`
}).join("")
return details ? `<span class="toastui-calendar-event-detail-items">${details}</span>` : ""
}renderCustomPopupDetails = (container, event, items, calendars) => {
const customItems = items.filter((item) => !nativeDetailItems.has(item))
if (!customItems.length) return true
const section = container.querySelector(".toastui-calendar-section-detail")
if (!section) return false
section.querySelectorAll(".toastui-calendar-custom-detail-item").forEach((element) => element.remove())
customItems.forEach((item) => {
const value = eventDetailValue(event, item, calendars)
if (value === "") return
const row = document.createElement("div")
row.className = "toastui-calendar-detail-item toastui-calendar-custom-detail-item"
const label = document.createElement("strong")
label.textContent = item + ": "
row.append(label, document.createTextNode(String(value)))
section.append(row)
})
return true
}parseDelimited = (text, sep) => {
const lines = (text || "").split(/\r?\n/).filter((line) => line.trim() !== "")
if (lines.length < 2) return []
const headers = lines[0].split(sep)
return lines.slice(1).map((line) => {
const values = line.split(sep)
const row = {}
headers.forEach((h, j) => {
const key = (h || "").trim()
const raw = (values[j] || "").trim()
if (!key) return
if (raw === "true") row[key] = true
else if (raw === "false") row[key] = false
else row[key] = raw
})
return row
})
}loadFileEvents = async (path, sep) => {
try {
const response = await fetch(path)
if (!response.ok) return {events: [], error: "Could not load " + path + " (" + response.status + ")"}
const text = await response.text()
return {events: parseDelimited(text, sep), error: ""}
} catch (err) {
return {events: [], error: "Could not load " + path}
}
}defaultEvents = [
{id: "fallback-1", calendarId: "cal1", title: "Fallback Kickoff", category: "time", start: "2026-04-13T09:00:00", end: "2026-04-13T10:00:00", location: "Room North"},
{id: "fallback-2", calendarId: "cal1", title: "Fallback Workshop", category: "time", start: "2026-04-14T13:00:00", end: "2026-04-14T15:00:00", location: "Studio B"}
]calendarConfig = ({
defaultView: defaultView,
isReadOnly: isReadOnly,
usageStatistics: false,
gridSelection: true,
useDetailPopup: useDetailPopup,
week: {
startDayOfWeek: weekStartDay,
dayNames: splitNames(weekDayNames, ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]),
narrowWeekend: weekNarrowWeekend,
workweek: weekWorkweek,
showNowIndicator: weekShowNowIndicator,
hourStart: weekHourStart,
hourEnd: weekHourEnd,
eventView: weekEventView.length ? weekEventView : false,
taskView: weekTaskView.length ? weekTaskView : false
},
month: {
startDayOfWeek: monthStartDay,
dayNames: splitNames(monthDayNames, ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]),
narrowWeekend: monthNarrowWeekend,
workweek: monthWorkweek,
visibleWeeksCount: monthVisibleWeeksCount,
isAlways6Weeks: monthAlways6Weeks
},
timezone: {
zones: [{
timezoneName: "Europe/Stockholm",
timezoneOffset: 1,
displayLabel: "Stockholm"
}]
},
calendars: calendarDefinitions,
template: {
time: (event) => {
const title = escapeHtml(event.title)
const titleAndTime = event.start ? `<strong>${formatEventTime(event.start)}</strong> ${title}` : title
return titleAndTime + renderEventDetails(event, eventDetailItems, calendarDefinitions)
}
}
})naiveDateTimePattern = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/
function tzOffsetMinutes(date, timeZone) {
const parts = {}
new Intl.DateTimeFormat("en-US", {
timeZone, hourCycle: "h23",
year: "numeric", month: "2-digit", day: "2-digit",
hour: "2-digit", minute: "2-digit", second: "2-digit"
}).formatToParts(date).forEach((p) => { parts[p.type] = p.value })
const asUTC = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second)
return (asUTC - date.getTime()) / 60000
}
function wallTimeToUtcIso(value, timeZone) {
const match = naiveDateTimePattern.exec(value)
if (!match) return null
const guess = Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]), Number(match[4]), Number(match[5]), match[6] ? Number(match[6]) : 0)
const offset = tzOffsetMinutes(new Date(guess), timeZone)
let utcMs = guess - offset * 60000
const refined = tzOffsetMinutes(new Date(utcMs), timeZone)
if (refined !== offset) utcMs = guess - refined * 60000
return new Date(utcMs).toISOString()
}
function resolveEventTime(value, sourceTz) {
if (sourceTz && typeof value === "string") {
const converted = wallTimeToUtcIso(value, sourceTz)
if (converted) return converted
}
return value
}calendar = {
const mount = document.querySelector("#calendar-mount")
const preview = document.querySelector("#config-preview")
const toYAML = (value, level = 0) => {
const indent = " ".repeat(level)
if (value == null) return "null"
if (typeof value === "string") {
if (value === "") return "''"
return "'" + value.replace(/'/g, "''") + "'"
}
if (typeof value === "number" || typeof value === "boolean") return String(value)
if (Array.isArray(value)) {
if (!value.length) return "[]"
return value.map((item) => {
if (item && typeof item === "object") {
const block = toYAML(item, level + 1)
const lines = block.split("\n")
return indent + "- " + lines[0] + (lines.length > 1 ? "\n" + lines.slice(1).map((line) => indent + " " + line).join("\n") : "")
}
return indent + "- " + toYAML(item, level + 1)
}).join("\n")
}
const entries = Object.entries(value).filter(([, v]) => v !== undefined)
if (!entries.length) return "{}"
return entries.map(([key, val]) => {
if (val && typeof val === "object") {
return indent + key + ":\n" + toYAML(val, level + 1)
}
return indent + key + ": " + toYAML(val, level + 1)
}).join("\n")
}
const {template, ...previewCalendarConfig} = calendarConfig
const payload = {
toastui: {
calendar: previewCalendarConfig,
extension: {
height: heightPx + "px",
file: filePath,
"file-sep": fileSepRaw,
date: initialDate,
eventDetailItems: eventDetailItems,
popupDetailItems: popupDetailItems
},
runtime: {
eventsLoaded: eventList.length,
eventError: eventError || null
}
}
}
if (preview) preview.textContent = toYAML(payload)
if (!window.tui || !window.tui.Calendar) return null
if (!mount) return null
mount.style.height = heightPx + "px"
const popupDetailClasses = {
location: "toastui-calendar-detail-exclude-location",
recurrenceRule: "toastui-calendar-detail-exclude-recurrence-rule",
attendees: "toastui-calendar-detail-exclude-attendees",
state: "toastui-calendar-detail-exclude-state",
calendar: "toastui-calendar-detail-exclude-calendar",
body: "toastui-calendar-detail-exclude-body"
}
mount.classList.toggle("toastui-calendar-detail-popup-enabled", useDetailPopup)
Object.entries(popupDetailClasses).forEach(([item, className]) => {
mount.classList.toggle(className, useDetailPopup && !popupDetailItems.includes(item))
})
const hasCustomPopupDetails = popupDetailItems.some((item) => !nativeDetailItems.has(item))
mount.classList.toggle("toastui-calendar-detail-has-custom", useDetailPopup && hasCustomPopupDetails)
if (window.__playgroundCal && typeof window.__playgroundCal.destroy === "function") {
window.__playgroundCal.destroy()
}
mount.innerHTML = ""
const cal = new window.tui.Calendar(mount, calendarConfig)
window.__playgroundCal = cal
if (useDetailPopup && hasCustomPopupDetails) {
cal.on("clickEvent", (info) => {
const observer = new MutationObserver(() => {
if (renderCustomPopupDetails(mount, info.event, popupDetailItems, calendarDefinitions)) observer.disconnect()
})
observer.observe(mount, {childList: true, subtree: true})
window.setTimeout(() => {
renderCustomPopupDetails(mount, info.event, popupDetailItems, calendarDefinitions)
observer.disconnect()
}, 100)
})
}
const sourceTz = calendarConfig.timezone?.zones?.[0]?.timezoneName ?? null
const normalizedEvents = eventList.map((event, index) => {
const normalized = {...event}
if (normalized.id == null || normalized.id === "") normalized.id = `playground-event-${index}`
normalized.raw = {...normalized, ...(normalized.raw && typeof normalized.raw === "object" ? normalized.raw : {})}
if (!normalized.state) normalized.state = ""
if (normalized.attendees != null && !Array.isArray(normalized.attendees)) normalized.attendees = [String(normalized.attendees)]
if (!normalized.attendees || (Array.isArray(normalized.attendees) && normalized.attendees.length === 0)) normalized.attendees = null
if (!normalized.isAllday && normalized.category !== "allday") {
if (normalized.start != null) normalized.start = resolveEventTime(normalized.start, sourceTz)
if (normalized.end != null) normalized.end = resolveEventTime(normalized.end, sourceTz)
}
return normalized
})
if (normalizedEvents.length) cal.createEvents(normalizedEvents)
if (initialDate) cal.setDate(new Date(initialDate))
invalidation.then(() => { cal.destroy(); window.__playgroundCal = null })
return cal
}