Files
arr-releases/arr-calendar.js
T
droideparanoico fa442b1fef Initial release: arr-calendar widget with partial-services support
- Self-contained YML: all CSS inlined (no global.css edit needed)
- Vanilla JS client with MutationObserver bootstrap
- Slide+fade month navigation via rAF (works in this Dynacat build)
- Popover portaled to <body> to escape backdrop-filter containing blocks
- Per-service enabled flag, unconfigured services silently skipped
- Status <ul> exposes per-service configured/ok flags for client hints
2026-06-16 09:55:30 +00:00

635 lines
23 KiB
JavaScript

(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 <body> 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 <body> 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 <body> 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 <body>. 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 <body>.
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 <script src="arr-calendar.js"> tag inside the widget
// is never executed, and the initial scan() finds zero widgets).
function scan() {
var widgets = document.querySelectorAll('.arr-calendar-widget:not([data-arr-inited])');
for (var i = 0; i < widgets.length; i++) {
initWidget(widgets[i]);
}
}
function startObserver() {
scan();
var observer = new MutationObserver(function (mutations) {
var found = false;
for (var i = 0; i < mutations.length; i++) {
var added = mutations[i].addedNodes;
for (var j = 0; j < added.length; j++) {
var n = added[j];
if (n.nodeType !== 1) continue;
if (n.classList && n.classList.contains('arr-calendar-widget')) {
initWidget(n);
found = true;
} else if (n.querySelectorAll) {
var inner = n.querySelectorAll('.arr-calendar-widget');
for (var k = 0; k < inner.length; k++) {
initWidget(inner[k]);
found = true;
}
}
}
}
if (!found) scan();
});
observer.observe(document.body, { childList: true, subtree: true });
}
if (document.body) {
startObserver();
} else {
document.addEventListener('DOMContentLoaded', startObserver);
}
})();