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
{{ first_post }}- linked title and date of your first post.{{ last_post }}- linked title and date of your most recent post.{{ total_posts }}- total number of posts.{{ years_blogging }}- time since your first post (e.g. "3 years, 2 months, and 5 days").{{ average_posts }}- average posts per month since your first post.{{ most_active_month }}- the month with the most posts.
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">←</button>
<span id="cb-year"></span>
<button id="cb-next" type="button" aria-label="Next year">→</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.
Requires JavaScript, available on Bear Blog's premium plan.↩
Built for the Bearming theme. Using a different theme? Add the Bearming tokens to make them work with your setup.↩