Robert Birming

CalenBear

  1. Bear Blog library
  2. Widgets

If you had a blog in 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 blog calendars are almost extinct. CalenBear brings them back in a modern one-column format.1

It shows your full posting history as a year-by-year calendar, with every day you published clickable. There's also an option to show a few fun stats about your blogging.

Preview

My own blog, from the first post until today:

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 }}.

How to use

Add the markup below wherever you want the calendar to appear, then add the script and styles to your theme. The posts embed on the first line gives the script your posts to work with, and the list is hidden once the calendar is built.

The summary is optional. Leave out the cb-summary div if you don't want it, or write your own sentence using any of these 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 safeLink(url, title) {
    const a = document.createElement("a");
    a.textContent = title;
    try {
      const parsed = new URL(url);
      a.href = (parsed.protocol === "https:" || parsed.protocol === "http:")
        ? parsed.href
        : "#";
    } catch {
      a.href = "#";
    }
    a.rel = "noopener noreferrer";
    return a;
  }

  function replacePlaceholder(container, placeholder, buildFn) {
    container.childNodes.forEach(function (node) {
      if (node.nodeType !== Node.TEXT_NODE) return;
      const marker = "{{ " + placeholder + " }}";
      const idx = node.textContent.indexOf(marker);
      if (idx === -1) return;

      const before = document.createTextNode(node.textContent.slice(0, idx));
      const after  = document.createTextNode(node.textContent.slice(idx + marker.length));
      const insert = buildFn();

      node.parentNode.insertBefore(before, node);
      if (Array.isArray(insert)) {
        insert.forEach(function (n) { node.parentNode.insertBefore(n, node); });
      } else {
        node.parentNode.insertBefore(insert, node);
      }
      node.parentNode.insertBefore(after, node);
      node.parentNode.removeChild(node);
    });
  }

  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];

      totalPosts++;
      const mo = key.slice(0, 7);
      monthCounts[mo] = (monthCounts[mo] || 0) + 1;

      posts[key] = { title: link.textContent.trim(), url: link.href };
    });

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

    sourceList.hidden = true;

    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) {
      replacePlaceholder(summary, "first_post", function () {
        const link = safeLink(firstPost.url, firstPost.title);
        return [link, document.createTextNode(" on " + formatDate(firstDate))];
      });
      replacePlaceholder(summary, "last_post", function () {
        const link = safeLink(lastPost.url, lastPost.title);
        return [link, document.createTextNode(" on " + formatDate(lastDate))];
      });
      replacePlaceholder(summary, "total_posts", function () {
        return document.createTextNode(totalPosts + (totalPosts === 1 ? " post" : " posts"));
      });
      replacePlaceholder(summary, "years_blogging", function () {
        return document.createTextNode(blogAge(firstDate, now));
      });
      replacePlaceholder(summary, "average_posts", function () {
        return document.createTextNode(avgPosts);
      });
      replacePlaceholder(summary, "most_active_month", function () {
        return document.createTextNode(mostActiveLabel);
      });
    }

    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.replaceChildren();

      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;

          if (posts[key]) {
            const dayEl = document.createElement("a");
            dayEl.className = "cb-day cb-day-post";
            dayEl.textContent = dy;
            dayEl.href = posts[key].url;
            dayEl.title = posts[key].title;
            dayGrid.appendChild(dayEl);
          } else {
            const dayEl = document.createElement("span");
            dayEl.className = "cb-day";
            dayEl.textContent = dy;
            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: 1.5rem;
}

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

.cb-nav button {
  font: inherit;
  background: none;
  border: none;
  padding: 0;
  cursor: pointer;
  font-size: 1rem;
  color: color-mix(in srgb, var(--text-color), transparent 40%);
  line-height: 1;
}

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

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

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

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

.cb-month {
  background: var(--code-background-color);
  border-radius: 6px;
  padding: 0.75rem;
}

.cb-month-name {
  font-size: calc(var(--font-scale) * 0.85);
  color: color-mix(in srgb, var(--text-color), transparent 40%);
  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: color-mix(in srgb, var(--text-color), transparent 40%);
  padding-block: 2px;
}

.cb-day {
  font-size: 0.7rem;
  padding-block: 3px;
  padding-inline: 1px;
  border-radius: 3px;
  color: color-mix(in srgb, var(--text-color), transparent 40%);
  line-height: 1.4;
}

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

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

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

Happy blogging, day by day.

Want more? Check out the full Bear Blog library.

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