MediaWiki:Gadget-home-calendar.js
MediaWiki interface page
More actions
Note: After publishing, you may have to bypass your browser's cache to see the changes.
- Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
- Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
- Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/* MediaWiki:Gadget-home-calendar.js */
(function () {
var DEFAULT_TZ = 'America/New_York';
var DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
var DATE_TIME_RE = /^\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}$/;
var DURATION_PART_RE = /(\d+(?:\.\d+)?)\s*(d|h|m)\b/gi;
var MONTHS = ['January','February','March','April','May','June', 'July','August','September','October','November','December'];
function pad2(n) {
n = String(n);
return n.length < 2 ? ('0' + n) : n;
}
function escapeHtml(str) {
str = String(str);
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function filePathUrl(fileName) {
fileName = String(fileName || '').replace(/^\s+|\s+$/g, '');
if (!fileName) return '';
return mw.util.getUrl('Special:FilePath/' + encodeURIComponent(fileName));
}
function ordinal(n) {
if (n % 100 >= 11 && n % 100 <= 13) return 'th';
if (n % 10 === 1) return 'st';
if (n % 10 === 2) return 'nd';
if (n % 10 === 3) return 'rd';
return 'th';
}
function plural(n, one, many) {
return n + ' ' + (n === 1 ? one : many);
}
function formatDuration(ms) {
ms = Math.max(0, ms);
var mins = Math.max(1, Math.ceil(ms / 60000));
if (mins < 60) return plural(mins, 'min', 'mins');
var hours = Math.floor(mins / 60);
var rem = mins % 60;
if (hours < 24) {
return rem ? (plural(hours,'hour','hours') + ' ' + plural(rem,'min','mins'))
: plural(hours,'hour','hours');
}
var days = Math.floor(hours / 24);
var remH = hours % 24;
return remH ? (plural(days,'day','days') + ' ' + plural(remH,'hour','hours'))
: plural(days,'day','days');
}
function scheduleMinuteAligned(fn) {
var now = Date.now();
var delay = 60000 - (now % 60000);
setTimeout(function () {
fn();
setInterval(fn, 60000);
}, delay);
}
function parseSpan(raw) {
if (raw === undefined || raw === null || raw === '') return null;
var s = String(raw).replace(/^\s+|\s+$/g, '').toLowerCase();
if (!s) return null;
var out = {};
var match;
var matchedAnything = false;
DURATION_PART_RE.lastIndex = 0;
while ((match = DURATION_PART_RE.exec(s))) {
var num = Number(match[1]);
var unit = match[2];
if (!isFinite(num) || num <= 0) continue;
matchedAnything = true;
if (unit === 'd') out.days = (out.days || 0) + num;
else if (unit === 'h') out.hours = (out.hours || 0) + num;
else if (unit === 'm') out.minutes = (out.minutes || 0) + num;
}
return matchedAnything ? out : null;
}
mw.hook('wikipage.content').add(function ($content) {
var root = $content.find('.gtw-cal[data-gtw-cal]')[0];
if (!root) return;
if (root.__gtwCalInit) return;
root.__gtwCalInit = true;
var dataEl = root.querySelector('.gtw-cal__data');
var mountEl = root.querySelector('.gtw-cal__mount');
var daysEl = root.querySelector('.gtw-cal__days');
var clockEl = root.querySelector('.gtw-cal__clock');
var todayBtn = root.querySelector('.gtw-cal__today');
var toggleBtn = root.querySelector('.gtw-cal__toggle');
if (!dataEl || !mountEl || !daysEl || !clockEl || !todayBtn || !toggleBtn) return;
var cfg = {};
try {
var rawCfg = (dataEl.textContent || '').replace(/^\s+|\s+$/g, '') || '{}';
cfg = JSON.parse(rawCfg);
} catch (e) {
cfg = {};
}
var TZ = cfg.timezone || DEFAULT_TZ;
var items = Array.isArray(cfg.items) ? cfg.items : [];
if (!(window.luxon && window.luxon.DateTime)) {
mountEl.innerHTML = '<p class="gtw-cal__error">Luxon missing; calendar cannot parse times.</p>';
return;
}
var Luxon = window.luxon;
var use24h = true;
var clockFormatter, fullFormatter;
function rebuildFormatters() {
clockFormatter = new Intl.DateTimeFormat('en-US', {
timeZone: TZ,
weekday: 'short',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: !use24h,
timeZoneName: 'short'
});
fullFormatter = new Intl.DateTimeFormat('en-US', {
timeZone: TZ,
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: !use24h,
timeZoneName: 'short'
});
}
rebuildFormatters();
// ---------- TZ-safe key helpers (Luxon) ----------
function nowDT() {
return Luxon.DateTime.now().setZone(TZ);
}
function dateKeyFromDT(dt) {
return dt.toFormat('yyyy-LL-dd');
}
function dateKeyNow() {
return dateKeyFromDT(nowDT());
}
function dateKeyFromMs(ms) {
return dateKeyFromDT(Luxon.DateTime.fromMillis(ms, { zone: TZ }));
}
function keyToDT(key) {
return Luxon.DateTime.fromFormat(key, 'yyyy-LL-dd', { zone: TZ });
}
function addDaysToKey(key, add) {
return dateKeyFromDT(keyToDT(key).plus({ days: add }));
}
function dayDelta(fromKey, toKey) {
var a = keyToDT(fromKey).startOf('day');
var b = keyToDT(toKey).startOf('day');
return Math.round(b.diff(a, 'days').days);
}
function daysInMonth(year, month) {
return Luxon.DateTime.fromObject({ year: year, month: month, day: 1 }, { zone: TZ }).daysInMonth;
}
function fullDateLabelFromKey(key) {
var a = key.split('-');
var y = Number(a[0]), m = Number(a[1]), d = Number(a[2]);
return d + ordinal(d) + ' ' + MONTHS[m - 1] + ' ' + y;
}
// ---------- Parsing wall-clock into millis ----------
function parseWallClockToMs(s) {
var dt = Luxon.DateTime.fromFormat(s, 'yyyy-MM-dd HH:mm', { zone: TZ });
if (!dt.isValid) throw new Error('Invalid time: ' + s);
return dt.toMillis();
}
function parseDateToMs(raw) {
var s = String(raw || '').replace(/^\s+|\s+$/g, '');
if (!s) throw new Error('Empty date');
if (DATE_ONLY_RE.test(s)) return parseWallClockToMs(s + ' 00:00');
if (DATE_TIME_RE.test(s)) return parseWallClockToMs(s);
throw new Error('Bad date format: ' + s);
}
// ---------- Event normalization ----------
function normalizeItem(raw) {
raw = raw || {};
var id = String(raw.id || '').replace(/^\s+|\s+$/g, '');
var title = String(raw.title || '').replace(/^\s+|\s+$/g, '');
var date = String(raw.date || '').replace(/^\s+|\s+$/g, '');
if (!id || !title || !date) return null;
var everySpan = parseSpan(raw.every);
var isRecurring = !!everySpan;
var durSpan = parseSpan(raw.duration) || { days: 1 };
return {
id: id,
title: title,
date: date,
note: typeof raw.note === 'string' ? raw.note : '',
placement: raw.placement === 'start-day' ? 'start-day' : 'span',
isRecurring: isRecurring,
everySpan: everySpan, // {days?, hours?, minutes?} or null
durationSpan: durSpan, // {days?, hours?, minutes?}
link: typeof raw.link === 'string' ? raw.link : '',
sprite: typeof raw.sprite === 'string' ? raw.sprite : '',
banner: typeof raw.banner === 'string' ? raw.banner : ''
};
}
function cloneObj(base) {
var out = {};
for (var k in base) {
if (Object.prototype.hasOwnProperty.call(base, k)) out[k] = base[k];
}
return out;
}
function endMsExclFromStart(startMs, item) {
var span = item.durationSpan || { days: 1 };
return Luxon.DateTime.fromMillis(startMs, { zone: TZ })
.plus(span)
.toMillis();
}
function buildOneOff(item) {
var startMs = parseDateToMs(item.date);
var ev = cloneObj(item);
ev.startMs = startMs;
ev.endMsExcl = endMsExclFromStart(startMs, item);
return ev;
}
function addEvery(startMs, item) {
var span = item.everySpan;
if (!span) return startMs;
return Luxon.DateTime.fromMillis(startMs, { zone: TZ })
.plus(span)
.toMillis();
}
function buildRecurring(item, year, month) {
var monthStart = Luxon.DateTime.fromObject({ year: year, month: month, day: 1 }, { zone: TZ });
var nextMonthStart = monthStart.plus({ months: 1 });
var monthStartMs = monthStart.toMillis();
var nextMonthStartMs = nextMonthStart.toMillis();
var anchorMs = parseDateToMs(item.date);
var out = [];
var guard = 0;
var startMs = anchorMs;
while (guard < 5000 && endMsExclFromStart(startMs, item) <= monthStartMs) {
startMs = addEvery(startMs, item);
guard++;
}
var idx = 0;
while (idx < 2000 && startMs < nextMonthStartMs) {
var endMsExcl = endMsExclFromStart(startMs, item);
if (endMsExcl > monthStartMs) {
var ev = cloneObj(item);
ev.id = item.id + '-' + year + '-' + pad2(month) + '-' + idx;
ev.startMs = startMs;
ev.endMsExcl = endMsExcl;
out.push(ev);
}
startMs = addEvery(startMs, item);
idx++;
}
return out;
}
function buildMonthEvents(year, month) {
var out = [];
for (var i = 0; i < items.length; i++) {
var it = normalizeItem(items[i]);
if (!it) continue;
if (it.isRecurring) {
var many = buildRecurring(it, year, month);
for (var j = 0; j < many.length; j++) out.push(many[j]);
} else {
out.push(buildOneOff(it));
}
}
out.sort(function (a, b) { return a.startMs - b.startMs; });
return out;
}
function buildByDay(year, month) {
var dim = daysInMonth(year, month);
var monthStartKey = year + '-' + pad2(month) + '-01';
var monthEndKey = year + '-' + pad2(month) + '-' + pad2(dim);
var by = {};
var events = buildMonthEvents(year, month);
for (var i = 0; i < events.length; i++) {
var ev = events[i];
var startKey = dateKeyFromMs(ev.startMs);
var endKey = dateKeyFromMs(ev.endMsExcl - 1);
var k = (startKey < monthStartKey) ? monthStartKey : startKey;
var last = (endKey > monthEndKey) ? monthEndKey : endKey;
if (last < monthStartKey || k > monthEndKey) continue;
if (ev.placement === 'start-day') {
if (!by[k]) by[k] = [];
by[k].push(ev);
continue;
}
for (var guard = 0; guard < 400; guard++) {
if (!by[k]) by[k] = [];
by[k].push(ev);
if (k === last) break;
k = addDaysToKey(k, 1);
}
}
for (var key in by) {
if (!Object.prototype.hasOwnProperty.call(by, key)) continue;
by[key].sort(function (a, b) { return a.startMs - b.startMs; });
}
return by;
}
function describeDayPosition(delta) {
if (delta === 0) return { text: 'Today', className: 'is-today' };
if (delta === 1) return { text: 'Tomorrow', className: 'is-tomorrow' };
if (delta === -1) return { text: 'Yesterday', className: 'is-past' };
if (delta > 1) return { text: 'In ' + plural(delta,'day','days'), className: 'is-future' };
return { text: plural(Math.abs(delta),'day','days') + ' ago', className: 'is-past' };
}
function describeEventTiming(nowMs, startMs, endMsExcl) {
if (nowMs < startMs) return { text: 'Starts in ' + formatDuration(startMs - nowMs), className: 'is-upcoming' };
if (nowMs < endMsExcl) return { text: 'Live • ends in ' + formatDuration(endMsExcl - nowMs), className: 'is-live' };
return { text: 'Ended ' + formatDuration(nowMs - endMsExcl) + ' ago', className: 'is-ended' };
}
function formatClock() {
return clockFormatter.format(new Date());
}
function formatFull(ms) {
return fullFormatter.format(new Date(ms));
}
function tooltip(startMs, endMsExcl) {
return 'Starts: ' + formatFull(startMs) + '\nEnds: ' + formatFull(endMsExcl);
}
function setEdgeSpacersForTarget(target) {
var spacerSize = Math.max(24, Math.floor((daysEl.clientHeight - target.offsetHeight) / 2));
var top = mountEl.querySelector('[data-edge="top"]');
var bottom = mountEl.querySelector('[data-edge="bottom"]');
if (top) top.style.height = spacerSize + 'px';
if (bottom) bottom.style.height = spacerSize + 'px';
}
function scrollCenter(target, behavior) {
behavior = behavior || 'auto';
var containerRect = daysEl.getBoundingClientRect();
var targetRect = target.getBoundingClientRect();
var targetTop = targetRect.top - containerRect.top + daysEl.scrollTop;
var top = targetTop - (daysEl.clientHeight - targetRect.height) / 2;
var maxTop = Math.max(0, daysEl.scrollHeight - daysEl.clientHeight);
daysEl.scrollTo({ top: Math.max(0, Math.min(maxTop, top)), behavior: behavior });
}
function centerToday(behavior) {
behavior = behavior || 'auto';
var todayKey = dateKeyNow();
var target = mountEl.querySelector('[data-date-key="' + todayKey + '"]') ||
mountEl.querySelector('.gtw-cal__day.is-today');
if (!target) return;
setEdgeSpacersForTarget(target);
scrollCenter(target, behavior);
}
function renderMonth() {
var now = nowDT();
var year = now.year;
var month = now.month;
var dim = daysInMonth(year, month);
var byDay = buildByDay(year, month);
var todayKey = dateKeyNow();
var blocks = [];
blocks.push('<div class="gtw-cal__edge" data-edge="top"></div>');
for (var day = 1; day <= dim; day++) {
var key = year + '-' + pad2(month) + '-' + pad2(day);
var delta = dayDelta(todayKey, key);
var dayStatus = describeDayPosition(delta);
var list = byDay[key] || [];
var dayBannerUrl = '';
if (list.length && list[0] && list[0].banner) {
dayBannerUrl = filePathUrl(list[0].banner);
}
var bannerStyle = '';
if (dayBannerUrl) {
var safeUrl = String(dayBannerUrl).replace(/'/g, "\\'");
bannerStyle =
' style="' +
"--gtw-day-banner:url('" + safeUrl + "');" +
"--gtw-day-banner-size:96px 96px;" +
'"';
}
var eventsHtml = '';
if (list.length) {
var lis = [];
for (var i = 0; i < list.length; i++) {
var ev = list[i];
var titleInner = escapeHtml(ev.title);
if (ev.link) {
var href = mw.util.getUrl(String(ev.link).replace(/^\s+|\s+$/g, ''));
titleInner = '<a href="' + escapeHtml(href) + '">' + titleInner + '</a>';
}
var spriteHtml = '';
if (ev.sprite) {
var spriteUrl = filePathUrl(ev.sprite);
if (spriteUrl) {
spriteHtml =
'<span class="growsprite">' +
'<img src="' + escapeHtml(spriteUrl) + '" decoding="async" loading="lazy" width="32" height="32" alt="">' +
'</span>';
}
}
lis.push(
'<li>' +
'<div class="gtw-cal__eventLine">' +
'<span class="gtw-cal__eventTitleRow">' +
spriteHtml +
'<span class="gtw-cal__eventTitle">' + titleInner + '</span>' +
'</span>' +
'<span class="gtw-chip gtw-cal__eventStatus" data-start-ms="' + ev.startMs + '" data-end-ms-excl="' + ev.endMsExcl + '"></span>' +
'</div>' +
(ev.note ? ('<p class="gtw-cal__eventNote">' + escapeHtml(ev.note) + '</p>') : '') +
'</li>'
);
}
eventsHtml = '<ul class="gtw-cal__events">' + lis.join('') + '</ul>';
} else {
eventsHtml = '<p class="gtw-cal__noEvents">No events.</p>';
}
blocks.push(
'<article class="gtw-cal__day ' + (delta === 0 ? 'is-today' : '') + '" data-date-key="' + key + '"' + bannerStyle + '>' +
'<div class="gtw-cal__dayHead">' +
'<h6>' + escapeHtml(fullDateLabelFromKey(key)) + '</h6>' +
'<span class="gtw-chip gtw-cal__dayStatus ' + dayStatus.className + '">' + escapeHtml(dayStatus.text) + '</span>' +
'</div>' +
eventsHtml +
'</article>'
);
}
blocks.push('<div class="gtw-cal__edge" data-edge="bottom"></div>');
mountEl.innerHTML = blocks.join('');
}
function refreshStatuses() {
var todayKey = dateKeyNow();
var nowMs = Date.now();
var dayBlocks = mountEl.querySelectorAll('[data-date-key]');
for (var i = 0; i < dayBlocks.length; i++) {
var block = dayBlocks[i];
var key = block.getAttribute('data-date-key');
var delta = dayDelta(todayKey, key);
if (delta === 0) block.classList.add('is-today');
else block.classList.remove('is-today');
var st = describeDayPosition(delta);
var node = block.querySelector('.gtw-cal__dayStatus');
if (node) {
node.textContent = st.text;
node.className = 'gtw-chip gtw-cal__dayStatus ' + st.className;
}
}
var chips = mountEl.querySelectorAll('.gtw-cal__eventStatus[data-start-ms][data-end-ms-excl]');
for (var c = 0; c < chips.length; c++) {
var chip = chips[c];
var startMs = Number(chip.getAttribute('data-start-ms'));
var endMsExcl = Number(chip.getAttribute('data-end-ms-excl'));
if (!isFinite(startMs) || !isFinite(endMsExcl)) continue;
var dayNode = chip.closest('[data-date-key]');
var dayKey = dayNode ? dayNode.getAttribute('data-date-key') : null;
var show = (dayKey === todayKey);
if (!show) {
chip.textContent = '';
chip.className = 'gtw-chip gtw-cal__eventStatus';
chip.removeAttribute('data-tip');
chip.style.display = 'none';
continue;
}
chip.style.display = '';
var st2 = describeEventTiming(nowMs, startMs, endMsExcl);
chip.textContent = st2.text;
chip.className = 'gtw-chip gtw-cal__eventStatus ' + st2.className;
chip.setAttribute('data-tip', tooltip(startMs, endMsExcl));
}
}
function tickClock() {
clockEl.textContent = formatClock();
}
toggleBtn.addEventListener('click', function (e) {
if (e && e.preventDefault) e.preventDefault();
use24h = !use24h;
toggleBtn.textContent = use24h ? '24h' : '12h';
rebuildFormatters();
tickClock();
refreshStatuses();
});
todayBtn.addEventListener('click', function (e) {
if (e && e.preventDefault) e.preventDefault();
centerToday('smooth');
});
renderMonth();
refreshStatuses();
centerToday('auto');
tickClock();
scheduleMinuteAligned(tickClock);
setInterval(refreshStatuses, 30000);
window.addEventListener('resize', function () { centerToday('auto'); });
});
})();