Robert Birming

CalenBear - a Bear blog calendar

If you had a blog during the early years of blogging, your theme most certainly had a sidebar. A place for recent posts, a blogroll, a short "about me" note, and possibly a calendar.

You rarely see two or three column blogs these days. And when it comes to calendars, they are almost extinct.

How about bringing them back to life in a modern one-column format? I wanted to give it a go. You can see the result on my blog calendar page.

The add-on displays your full posting history as a year-by-year calendar, with each published day clickable. There's also an option to show a summary of your blogging stats. Scroll down to add the CalenBear to your blog.1 2

How to use

Add the markup below wherever you want the calendar to appear, then add the script and styles to your theme.

Optionally, add a <div id="cb-summary"> anywhere on the page to display some fun stats. These options are available:

Placeholders

Markup

{{ posts }}

<div id="cb-summary">

Since {{ first_post }}, I have published {{ total_posts }} over {{ years_blogging }}, an average of {{ average_posts }} per month. My most active month was {{ most_active_month }}.

</div>

<div id="cb-wrap">
  <div class="cb-nav">
    <button id="cb-prev" type="button" aria-label="Previous year">&#8592;</button>
    <span id="cb-year"></span>
    <button id="cb-next" type="button" aria-label="Next year">&#8594;</button>
  </div>
  <div id="cb-grid"></div>
</div>

Script

<script>
/* CalenBear | robertbirming.com */
(function () {
  "use strict";

  function formatDate(date) {
    return date.toLocaleDateString("en-GB", {
      day: "numeric",
      month: "long",
      year: "numeric",
      timeZone: "UTC"
    });
  }

  function blogAge(from, to) {
    const fromY = from.getUTCFullYear();
    const fromM = from.getUTCMonth();
    const fromD = from.getUTCDate();
    const toY = to.getUTCFullYear();
    const toM = to.getUTCMonth();
    const toD = to.getUTCDate();

    let years = toY - fromY;
    let months = toM - fromM;
    let days = toD - fromD;

    if (days < 0) {
      months--;
      days += new Date(Date.UTC(toY, toM, 0)).getUTCDate();
    }
    if (months < 0) {
      years--;
      months += 12;
    }

    const segments = [];
    if (years > 0) segments.push(years + (years === 1 ? " year" : " years"));
    if (months > 0) segments.push(months + (months === 1 ? " month" : " months"));
    if (days > 0 || segments.length === 0) segments.push(days + (days === 1 ? " day" : " days"));

    if (segments.length === 1) return segments[0];
    if (segments.length === 2) return segments[0] + " and " + segments[1];
    return segments[0] + ", " + segments[1] + ", and " + segments[2];
  }

  function replacePlaceholder(html, placeholder, replacement) {
    return html.split("{{ " + placeholder + " }}").join(replacement);
  }

  function init() {
    const sourceList =
      document.querySelector("ul.embedded.blog-posts") ||
      document.querySelector("ul.blog-posts");
    if (!sourceList) return;

    const items = Array.from(sourceList.querySelectorAll("li"));
    if (!items.length) return;

    const posts = {};
    let totalPosts = 0;
    const monthCounts = {};

    items.forEach(function (li) {
      const time = li.querySelector("time[datetime]");
      const link = li.querySelector("a");
      if (!time || !link) return;

      const dt = time.getAttribute("datetime");
      const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(dt);
      if (!match) return;

      const key = match[1] + "-" + match[2] + "-" + match[3];

      // Count every post for stats
      totalPosts++;
      const mo = key.slice(0, 7);
      monthCounts[mo] = (monthCounts[mo] || 0) + 1;

      // Calendar keeps last post per day
      posts[key] = { title: link.textContent.trim(), url: link.href };
    });

    sourceList.hidden = true;

    const keys = Object.keys(posts).sort();
    if (!keys.length) return;

    const postYears = keys.map(function (k) { return parseInt(k.slice(0, 4), 10); });
    const minYear = Math.min(...postYears);
    const maxYear = Math.max(...postYears);
    let currentYear = maxYear;

    const firstKey = keys[0];
    const lastKey = keys[keys.length - 1];
    const firstPost = posts[firstKey];
    const lastPost = posts[lastKey];

    const firstDate = new Date(firstKey + "T00:00:00Z");
    const lastDate = new Date(lastKey + "T00:00:00Z");
    const now = new Date();

    const monthsSinceFirst =
      (now.getUTCFullYear() - firstDate.getUTCFullYear()) * 12 +
      (now.getUTCMonth() - firstDate.getUTCMonth()) + 1;
    const avgPosts = (totalPosts / monthsSinceFirst).toFixed(1);

    const mostActiveMonth = Object.keys(monthCounts).reduce(function (a, b) {
      return monthCounts[a] >= monthCounts[b] ? a : b;
    });
    const mostActiveDate = new Date(mostActiveMonth + "-01T00:00:00Z");
    const mostActiveLabel = mostActiveDate.toLocaleDateString("en-GB", {
      month: "long", year: "numeric", timeZone: "UTC"
    });

    const summary = document.getElementById("cb-summary");
    if (summary) {
      let html = summary.innerHTML;

      html = replacePlaceholder(html, "first_post",
        "<a href=\"" + firstPost.url + "\">" + firstPost.title + "</a> on " + formatDate(firstDate));
      html = replacePlaceholder(html, "last_post",
        "<a href=\"" + lastPost.url + "\">" + lastPost.title + "</a> on " + formatDate(lastDate));
      html = replacePlaceholder(html, "total_posts",
        totalPosts + (totalPosts === 1 ? " post" : " posts"));
      html = replacePlaceholder(html, "years_blogging", blogAge(firstDate, now));
      html = replacePlaceholder(html, "average_posts", avgPosts);
      html = replacePlaceholder(html, "most_active_month", mostActiveLabel);

      summary.innerHTML = html;
    }

    const monthNames = [
      "January", "February", "March", "April",
      "May", "June", "July", "August",
      "September", "October", "November", "December"
    ];
    const dayNames = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];

    const yearEl = document.getElementById("cb-year");
    const grid = document.getElementById("cb-grid");
    const prevBtn = document.getElementById("cb-prev");
    const nextBtn = document.getElementById("cb-next");

    function render() {
      yearEl.textContent = currentYear;
      prevBtn.disabled = currentYear <= minYear;
      nextBtn.disabled = currentYear >= maxYear;
      grid.innerHTML = "";

      for (let mo = 0; mo < 12; mo++) {
        const cell = document.createElement("div");
        cell.className = "cb-month";

        const heading = document.createElement("div");
        heading.className = "cb-month-name";
        heading.textContent = monthNames[mo];
        cell.appendChild(heading);

        const dayGrid = document.createElement("div");
        dayGrid.className = "cb-days";

        dayNames.forEach(function (d) {
          const lbl = document.createElement("span");
          lbl.className = "cb-day-label";
          lbl.textContent = d;
          dayGrid.appendChild(lbl);
        });

        const firstDay = new Date(currentYear, mo, 1).getDay();
        const offset = (firstDay + 6) % 7;
        const daysInMonth = new Date(currentYear, mo + 1, 0).getDate();

        for (let i = 0; i < offset; i++) {
          const empty = document.createElement("span");
          empty.className = "cb-day cb-day-empty";
          dayGrid.appendChild(empty);
        }

        for (let dy = 1; dy <= daysInMonth; dy++) {
          const moStr = String(mo + 1).padStart(2, "0");
          const dyStr = String(dy).padStart(2, "0");
          const key = currentYear + "-" + moStr + "-" + dyStr;
          const dayEl = document.createElement("span");
          dayEl.className = "cb-day";
          dayEl.textContent = dy;

          if (posts[key]) {
            dayEl.className += " cb-day-post";
            dayEl.title = posts[key].title;
            dayEl.setAttribute("role", "link");
            dayEl.setAttribute("tabindex", "0");

            (function (url) {
              dayEl.addEventListener("click", function () {
                window.location.href = url;
              });
              dayEl.addEventListener("keydown", function (e) {
                if (e.key === "Enter" || e.key === " ") {
                  e.preventDefault();
                  window.location.href = url;
                }
              });
            })(posts[key].url);
          }

          dayGrid.appendChild(dayEl);
        }

        cell.appendChild(dayGrid);
        grid.appendChild(cell);
      }
    }

    prevBtn.addEventListener("click", function () {
      if (currentYear > minYear) { currentYear--; render(); }
    });

    nextBtn.addEventListener("click", function () {
      if (currentYear < maxYear) { currentYear++; render(); }
    });

    render();
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", init, { once: true });
  } else {
    init();
  }
})();
</script>

Styles

/* CalenBear | robertbirming.com */
#cb-wrap {
  margin-block: var(--space-block);
}

.cb-nav {
  display: flex;
  align-items: center;
  gap: 1rem;
  margin-block-end: 1.5rem;
}

.cb-nav button {
  background: none;
  border: none;
  padding: 0;
  cursor: pointer;
  font-size: 1rem;
  color: var(--muted);
  line-height: 1;
}

@media (hover: hover) {
  .cb-nav button:not([disabled]):hover {
    color: var(--text);
  }
}

.cb-nav button[disabled] {
  opacity: 0.3;
  cursor: default;
}

#cb-year {
  font-size: 1rem;
  color: var(--text);
}

#cb-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
  gap: 1rem;
}

.cb-month {
  background: var(--surface);
  border-radius: var(--radius);
  padding: 0.75rem;
}

.cb-month-name {
  font-size: var(--font-small);
  color: var(--muted);
  margin-block-end: 0.5rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

.cb-days {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  gap: 1px;
  text-align: center;
}

.cb-day-label {
  font-size: 0.65rem;
  color: var(--muted);
  padding-block: 2px;
}

.cb-day {
  font-size: 0.7rem;
  padding-block: 3px;
  padding-inline: 1px;
  border-radius: 3px;
  color: var(--muted);
  line-height: 1.4;
}

.cb-day-empty {
  visibility: hidden;
}

.cb-day-post {
  background: color-mix(in srgb, var(--link) 12%, transparent);
  color: var(--link);
  font-weight: 500;
  cursor: pointer;
}

@media (hover: hover) {
  .cb-day-post:hover {
    background: var(--link);
    color: var(--bg);
  }
}

Browse more Bearming add-ons.

Happy blogging, and may your most active month always be this one.

  1. Requires JavaScript, available on Bear Blog's premium plan.

  2. Built for the Bearming theme. Using a different theme? Add the Bearming tokens to make them work with your setup.