(function () { 'use strict'; var MONTH_NAMES = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December' ]; var WEEKDAYS_MON_FIRST = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']; function pad2(n) { return n < 10 ? '0' + n : '' + n; } function parseISODate(s) { var parts = s.split('-'); return { y: parseInt(parts[0], 10), m: parseInt(parts[1], 10) - 1, d: parseInt(parts[2], 10) }; } function formatISO(y, m, d) { return y + '-' + pad2(m + 1) + '-' + pad2(d); } function daysInMonth(y, m) { return new Date(y, m + 1, 0).getDate(); } function collectReleases(widget) { var byDate = {}; var lis = widget.querySelectorAll('.arr-release-data > li'); for (var i = 0; i < lis.length; i++) { var li = lis[i]; var date = li.getAttribute('data-date'); if (!date) continue; if (!byDate[date]) byDate[date] = []; byDate[date].push({ service: li.getAttribute('data-service'), date: date, title: li.getAttribute('data-title') || '', subtitle: li.getAttribute('data-subtitle') || '', link: li.getAttribute('data-link') || '#', cover: li.getAttribute('data-cover') || '' }); } return byDate; } // Build the rows for the current month. The calendar always renders // exactly 6 weeks (42 day cells) so its height is constant across all // months. The first row of cells starts on `firstDayOfWeek` (Monday) // and may include spillover days from the previous month; the last // row may include spillover days from the next month. This matches // Glance's built-in calendar (FULL_MONTH_SLOTS = 7 * 6). // // Spillover formula matches Glance's calendar.js exactly: when the // month starts on firstDayOfWeek itself, we treat that as "a full // previous week" (7 spillover days), not zero — this keeps every row // aligned to a complete Mon..Sun week. function buildRows(year, month, firstDayOfWeek) { if (firstDayOfWeek === undefined) firstDayOfWeek = 0; // 0 = Monday var firstDay = new Date(year, month, 1); // Monday-first: convert JS getDay() (0=Sun..6=Sat) to 0=Mon..6=Sun var firstWeekday = (firstDay.getDay() + 6) % 7; var totalDays = daysInMonth(year, month); var prevMonthDays = daysInMonth(year, month - 1); // Header row (weekday labels) — separate from the day grid below. var rows = []; var headerRow = []; for (var w = 0; w < 7; w++) { headerRow.push({ type: 'weekday', text: WEEKDAYS_MON_FIRST[w] }); } rows.push(headerRow); // Distribute 42 cells into: prev-month spillover, current month, // next-month spillover. Matches Glance's `previousMonthSpilloverDays` // formula: when firstWeekday == firstDayOfWeek we render a full week // of prev-month spillover (7), not 0. var prevSpill = (firstWeekday - firstDayOfWeek + 7) % 7 || 7; // 1..7 var currentDays = totalDays; // 28..31 var nextSpill = 42 - (prevSpill + currentDays); // 4..14 // Sanity guard — the math always adds to 42 for valid months, but if // something pathological happens, clamp to keep the layout stable. if (nextSpill < 0) nextSpill = 0; // Build 6 rows of 7 cells each, filling in: // 1..prevSpill → previous-month spillover (from prev month) // prevSpill+1..prevSpill+currentDays → current month // the rest → next-month spillover var cellIndex = 0; for (var r = 0; r < 6; r++) { var row = []; for (var d = 0; d < 7; d++) { cellIndex++; if (cellIndex <= prevSpill) { // Previous-month spillover: day number is prevMonthDays - prevSpill + cellIndex row.push({ type: 'day', day: prevMonthDays - prevSpill + cellIndex, outsideMonth: true, iso: null, releases: [] }); } else if (cellIndex <= prevSpill + currentDays) { var dayOfMonth = cellIndex - prevSpill; var iso = formatISO(year, month, dayOfMonth); row.push({ type: 'day', day: dayOfMonth, outsideMonth: false, iso: iso, releases: [] // filled in by renderGrid }); } else { // Next-month spillover: day number starts at 1 var nextDay = cellIndex - (prevSpill + currentDays); row.push({ type: 'day', day: nextDay, outsideMonth: true, iso: null, releases: [] }); } } rows.push(row); } return rows; } // Animation constants matching the built-in Glance calendar's slide+fade // effect when navigating between months. Uses requestAnimationFrame to // manually drive the animation. This is the most reliable approach // because it doesn't depend on CSS transitions, Web Animations API, or // any specific browser behavior. var ANIM_DURATION_ENTER = 700; // Slide distance is 0.8rem — converted to pixels at call time using // the actual root font-size (Dynacat sets it to 10px, so 0.8rem ≈ 8px). var ANIM_DISTANCE_REM = 0.8; // Render the new content first (so navigation is responsive even if the // animation fails for any reason), then apply the slide+fade animation // as a visual flourish. function animateGridChange(widgetOrGrid, renderNew, dir) { if (dir !== -1) dir = 1; // Resolve the live grid element. var gridEl = widgetOrGrid; if (gridEl && gridEl.classList && !gridEl.classList.contains('arr-calendar-grid')) { gridEl = widgetOrGrid.querySelector('.arr-calendar-grid'); } if (!gridEl) { renderNew(); return; } // Compute the actual pixel distance based on the root font size. // (CSS rem resolves to the root font-size; Dynacat sets it to 10px.) var rootStyle = window.getComputedStyle(document.documentElement); var rootFontSize = parseFloat(rootStyle.fontSize) || 10; var distancePx = ANIM_DISTANCE_REM * rootFontSize; // Direction: // NEXT (forward in time): new month slides in from the RIGHT // PREV (backward in time): new month slides in from the LEFT var startX = dir > 0 ? distancePx : -distancePx; // Render the new content first. renderNew(); // Animate via rAF. We use a "linear-tween" approach: compute progress // over the duration, interpolate opacity and transform. var startTime = null; function step(now) { if (startTime === null) startTime = now; var elapsed = now - startTime; var progress = Math.min(elapsed / ANIM_DURATION_ENTER, 1); // Use the same easing as Glance's calendar: cubic-bezier(0.22, 1, 0.36, 1) // Approximated as a simple easeOutCubic for performance. var eased = 1 - Math.pow(1 - progress, 3); gridEl.style.opacity = String(eased); gridEl.style.transform = 'translateX(' + (startX * (1 - eased)) + 'px)'; if (progress < 1) { requestAnimationFrame(step); } else { // Clean up inline styles when done gridEl.style.opacity = ''; gridEl.style.transform = ''; } } requestAnimationFrame(step); } // Animate the undo button sliding in from the left (matches the built-in // Glance calendar's "calendar-undo-button" entrance animation). Uses // rAF-driven manual animation for reliability. function animateUndoEntrance(undoEl) { if (!undoEl || undoEl.hidden) return; // translateX(-100%) means shift by the element's own width; use // offsetWidth to compute the actual pixel distance. var startX = -undoEl.offsetWidth; var startTime = null; function step(now) { if (startTime === null) startTime = now; var elapsed = now - startTime; var progress = Math.min(elapsed / 400, 1); var eased = 1 - Math.pow(1 - progress, 3); undoEl.style.opacity = String(eased); undoEl.style.transform = 'translateX(' + (startX * (1 - eased)) + 'px)'; if (progress < 1) { requestAnimationFrame(step); } else { undoEl.style.opacity = ''; undoEl.style.transform = ''; } } requestAnimationFrame(step); } function renderGrid(widget, year, month, byDate, today, todayParts) { var monthEl = widget.querySelector('[data-arr-month]'); var yearEl = widget.querySelector('[data-arr-year]'); var monthNumEl = widget.querySelector('[data-arr-monthnum]'); var undoEl = widget.querySelector('[data-arr-undo]'); var gridEl = widget.querySelector('[data-arr-grid]'); monthEl.textContent = MONTH_NAMES[month]; yearEl.textContent = year; monthNumEl.textContent = pad2(month + 1); // Show undo button only when not viewing the current month var onCurrent = (year === todayParts.y && month === todayParts.m); undoEl.hidden = onCurrent; gridEl.innerHTML = ''; var rows = buildRows(year, month); for (var r = 0; r < rows.length; r++) { var row = rows[r]; // Group weekday cells: render header row as plain text cells if (row[0].type === 'weekday') { for (var i = 0; i < 7; i++) { var wd = document.createElement('div'); wd.className = 'arr-cal-weekday'; wd.textContent = row[i].text; gridEl.appendChild(wd); } continue; } for (var j = 0; j < 7; j++) { var cellData = row[j]; var cell = document.createElement('button'); cell.type = 'button'; cell.className = 'arr-cal-cell'; if (cellData.outsideMonth) { cell.classList.add('arr-cal-outside'); } else { var releases = byDate[cellData.iso] || []; cellData.releases = releases; cell.setAttribute('data-date', cellData.iso); cell.setAttribute('aria-label', cellData.iso + (releases.length > 0 ? ', ' + releases.length + ' releases' : '')); if (cellData.iso === today) cell.classList.add('arr-cal-today'); if (releases.length > 0) cell.classList.add('arr-cal-has-releases'); } var num = document.createElement('span'); num.className = 'arr-cal-num'; num.textContent = String(cellData.day); cell.appendChild(num); if (!cellData.outsideMonth && cellData.releases.length > 0) { var dots = document.createElement('span'); dots.className = 'arr-cal-dots'; var seen = {}; for (var k = 0; k < cellData.releases.length; k++) { var svc = cellData.releases[k].service; if (seen[svc]) continue; seen[svc] = true; var dot = document.createElement('span'); dot.className = 'arr-dot arr-dot-' + svc; dots.appendChild(dot); } cell.appendChild(dots); (function (cd, c) { c.addEventListener('click', function (ev) { ev.stopPropagation(); showPopover(widget, c, cd.releases); }); })(cellData, cell); } else if (!cellData.outsideMonth) { (function (c) { c.addEventListener('click', function (ev) { ev.stopPropagation(); hidePopover(widget); }); })(cell); } gridEl.appendChild(cell); } } } // Position the popover relative to the viewport (position: fixed) so it // can overlay any other widget on the page. The popover must live in // document.body, NOT inside the widget, because the widget is wrapped by // .widget-content (which has backdrop-filter: blur) — and backdrop-filter // creates a containing block for position:fixed descendants, which would // clip the popover to the widget. Moving it to
avoids that. function positionPopover(pop, cell) { var rect = cell.getBoundingClientRect(); var popRect = pop.getBoundingClientRect(); var margin = 8; var vw = window.innerWidth; var vh = window.innerHeight; // Default: place below the cell, horizontally aligned to its left edge var top = rect.bottom + margin; var left = rect.left; // If popover would overflow the bottom, place it above the cell if (top + popRect.height > vh - margin) { top = rect.top - popRect.height - margin; } // If still off-screen (cell near top), clamp to viewport if (top < margin) top = margin; // If popover would overflow the right edge, shift left if (left + popRect.width > vw - margin) { left = vw - popRect.width - margin; } if (left < margin) left = margin; pop.style.position = 'fixed'; pop.style.top = top + 'px'; pop.style.left = left + 'px'; } function showPopover(widget, cell, releases) { var pop = findPopover(widget); if (!pop) { // No popover element found anywhere — should not happen since the // widget template ships one, but be defensive. return; } pop.innerHTML = ''; var header = document.createElement('div'); header.className = 'arr-popover-header'; var iso = cell.getAttribute('data-date'); var d = parseISODate(iso); var headerDate = new Date(d.y, d.m, d.d); var opts = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }; header.textContent = headerDate.toLocaleDateString(undefined, opts); pop.appendChild(header); if (releases.length === 0) { var empty = document.createElement('div'); empty.className = 'arr-popover-empty'; empty.textContent = 'No releases on this day.'; pop.appendChild(empty); } else { var list = document.createElement('ul'); list.className = 'arr-popover-list'; releases.sort(function (a, b) { return a.service.localeCompare(b.service); }); for (var i = 0; i < releases.length; i++) { var rel = releases[i]; var item = document.createElement('li'); item.className = 'arr-popover-item arr-svc-' + rel.service; var cover = document.createElement('img'); cover.className = 'arr-popover-cover'; cover.loading = 'lazy'; cover.alt = ''; if (rel.cover) { cover.src = rel.cover; } else { cover.style.display = 'none'; } item.appendChild(cover); var text = document.createElement('div'); text.className = 'arr-popover-text'; var titleEl = document.createElement('a'); titleEl.className = 'arr-popover-title'; titleEl.href = rel.link; titleEl.target = '_blank'; titleEl.rel = 'noopener noreferrer'; titleEl.textContent = rel.title; text.appendChild(titleEl); var sub = document.createElement('div'); sub.className = 'arr-popover-sub'; var badge = document.createElement('span'); badge.className = 'arr-popover-badge arr-svc-badge-' + rel.service; badge.textContent = rel.service; sub.appendChild(badge); var subText = document.createTextNode(' ' + rel.subtitle); sub.appendChild(subText); text.appendChild(sub); item.appendChild(text); list.appendChild(item); } pop.appendChild(list); } // Move the popover out of the widget to so it can use // position:fixed relative to the viewport. The widget is wrapped by // .widget-content which has backdrop-filter, and backdrop-filter // creates a containing block for position:fixed descendants. if (pop.parentElement !== document.body) { document.body.appendChild(pop); } // Tag the popover with its owning widget id so we can find it again // after it's been moved (findPopover uses this). pop.dataset.widgetId = widget.dataset.arrId; // Make visible first (so getBoundingClientRect works), then position pop.hidden = false; pop.style.top = '0px'; pop.style.left = '0px'; // Force layout, then position relative to the clicked cell positionPopover(pop, cell); pop.dataset.date = iso; } function hidePopover(widget) { var pop = findPopover(widget); if (!pop) return; pop.hidden = true; pop.innerHTML = ''; delete pop.dataset.date; // Move the popover back inside the widget so it stays associated with it // (and so the next showPopover will find it there). if (pop.parentElement === document.body) { widget.appendChild(pop); } } function initWidget(widget) { ensureWidgetId(widget); if (widget.dataset.arrInited === '1') { // Already inited — but Dynacat's SSE may have re-morphed the widget // HTML in place. In that case the cells were replaced and lost their // click handlers. Detect a fresh grid (no children yet) and re-render. var existingGrid = widget.querySelector('.arr-calendar-grid'); if (existingGrid && existingGrid.children.length > 0) return; } widget.dataset.arrInited = '1'; var today = widget.getAttribute('data-today') || formatISO(new Date().getFullYear(), new Date().getMonth(), new Date().getDate()); var todayParts = parseISODate(today); var byDate = collectReleases(widget); var view = { y: todayParts.y, m: todayParts.m }; function render() { renderGrid(widget, view.y, view.m, byDate, today, todayParts); } // Track whether the undo button is currently visible, so we can decide // whether to animate its entrance on the next render. var undoWasVisible = false; // Capture the current grid state for animation, then re-render with // the new month and animate the change. The grid element is looked up // FRESH on each call so that the animation applies to the live grid // even if the widget was re-morphed by Dynacat's SSE update between // page load and now (a closure-captured reference would be stale). function navigateWithTransition(dir) { if (dir !== -1) dir = 1; var onCurrent = (view.y === todayParts.y && view.m === todayParts.m); var undoWillBeVisible = !onCurrent; animateGridChange(widget, function () { render(); }, dir); if (undoWillBeVisible && !undoWasVisible) { // Wait a tick so the DOM updates before animating setTimeout(function () { animateUndoEntrance(widget.querySelector('[data-arr-undo]')); }, 0); } undoWasVisible = undoWillBeVisible; } widget.querySelector('[data-arr-nav="prev"]').addEventListener('click', function () { view.m -= 1; if (view.m < 0) { view.m = 11; view.y -= 1; } hidePopover(widget); navigateWithTransition(-1); }); widget.querySelector('[data-arr-nav="next"]').addEventListener('click', function () { view.m += 1; if (view.m > 11) { view.m = 0; view.y += 1; } hidePopover(widget); navigateWithTransition(1); }); widget.querySelector('[data-arr-undo]').addEventListener('click', function () { // Direction: if current view is in the past, undo = forward (+1). // If in the future, undo = backward (-1). var dir = 1; if (view.y > todayParts.y || (view.y === todayParts.y && view.m > todayParts.m)) { dir = -1; } view.y = todayParts.y; view.m = todayParts.m; hidePopover(widget); navigateWithTransition(dir); }); render(); undoWasVisible = false; // initial render, undo is hidden } // Find the popover element. It normally lives inside the widget, but // during display it's moved to to escape the widget's // backdrop-filter containing block. Look in both places. function findPopover(widget) { var pop = widget.querySelector('[data-arr-popover]'); if (pop) return pop; // The popover may be on . Find it by its dataset/attribute. var bodyPops = document.body.querySelectorAll('[data-arr-popover].arr-calendar-popover'); for (var i = 0; i < bodyPops.length; i++) { var bp = bodyPops[i]; if (bp.dataset.widgetId === widget.dataset.arrId) return bp; } return null; } // Assign a stable id to the widget so we can find its popover on . function ensureWidgetId(widget) { if (!widget.dataset.arrId) { widget.dataset.arrId = 'arrcal_' + Math.random().toString(36).slice(2, 10); } return widget.dataset.arrId; } // Close popover when clicking outside any arr-calendar-widget document.addEventListener('click', function (ev) { var widgets = document.querySelectorAll('.arr-calendar-widget'); for (var i = 0; i < widgets.length; i++) { var w = widgets[i]; if (w.contains(ev.target)) continue; var pop = findPopover(w); if (pop && !pop.hidden) hidePopover(w); } }); // Esc closes popover document.addEventListener('keydown', function (ev) { if (ev.key !== 'Escape') return; var widgets = document.querySelectorAll('.arr-calendar-widget'); for (var i = 0; i < widgets.length; i++) { hidePopover(widgets[i]); } }); // Reposition popover on scroll/resize so it stays attached to its cell window.addEventListener('scroll', function () { var widgets = document.querySelectorAll('.arr-calendar-widget'); for (var i = 0; i < widgets.length; i++) { var widget = widgets[i]; var pop = findPopover(widget); if (!pop || pop.hidden) continue; var iso = pop.dataset.date; if (!iso) continue; var cell = widget.querySelector('.arr-cal-cell[data-date="' + iso + '"]'); if (cell) positionPopover(pop, cell); } }, true); window.addEventListener('resize', function () { var widgets = document.querySelectorAll('.arr-calendar-widget'); for (var i = 0; i < widgets.length; i++) { var widget = widgets[i]; var pop = findPopover(widget); if (!pop || pop.hidden) continue; var iso = pop.dataset.date; if (!iso) continue; var cell = widget.querySelector('.arr-cal-cell[data-date="' + iso + '"]'); if (cell) positionPopover(pop, cell); } }); // Boot: scan DOM now, then watch for widgets added later (Dynacat's // custom-api widgets are inserted via innerHTML/XHR after page load, // which means our