Toggle menu
Toggle preferences menu
Toggle personal menu
Not logged in
Your IP address will be publicly visible if you make any edits.

MediaWiki:Gadget-home-calendar.js

MediaWiki interface page

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, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;')
      .replace(/'/g, '&#039;');
  }

  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'); });
  });
})();