CalenBear - a Bear blog calendar
This 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
Preview
Head over to my blogging by numbers page to see a personalized version in action.
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 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: 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;
text-decoration: none;
}
@media (hover: hover) {
.cb-day-post:hover {
background: var(--link);
color: var(--bg);
}
}
Happy blogging, day by day.
Want more? Check out all available Bearming add-ons.
Built for the Bearming theme. Using a different theme? Add the Bearming tokens to make them work with your setup.↩
Requires JavaScript, available on Bear Blog's premium plan.↩