Skip to content

Instantly share code, notes, and snippets.

@audionerd
Last active April 1, 2026 21:03
Show Gist options
  • Select an option

  • Save audionerd/de354626d83e94c3c22eb13e302cb139 to your computer and use it in GitHub Desktop.

Select an option

Save audionerd/de354626d83e94c3c22eb13e302cb139 to your computer and use it in GitHub Desktop.
Hammerspoon script to add a mini calendar to the macOS menu bar
local PANEL_WIDTH = 300
local PANEL_HEIGHT = 226
local PANEL_TOP_MARGIN = 2
local MENUBAR_AUTOSAVE_NAME = "minical"
local MENUBAR_TITLE = "📅"
local function normalizeMonth(year, month)
while month < 1 do
month = month + 12
year = year - 1
end
while month > 12 do
month = month - 12
year = year + 1
end
return year, month
end
local function daysInMonth(year, month)
return os.date("*t", os.time({ year = year, month = month + 1, day = 0, hour = 12 })).day
end
local function monthTitle(year, month)
return os.date("%B %Y", os.time({ year = year, month = month, day = 1, hour = 12 }))
end
local function buildCalendarWeeks(year, month)
local today = os.date("*t")
local firstWday = os.date("*t", os.time({ year = year, month = month, day = 1, hour = 12 })).wday
local currentMonthDays = daysInMonth(year, month)
local prevYear, prevMonth = normalizeMonth(year, month - 1)
local prevMonthDays = daysInMonth(prevYear, prevMonth)
local lead = firstWday - 1
local total = lead + currentMonthDays
local trail = (7 - (total % 7)) % 7
local count = total + trail
local cells = {}
local startDay = 1 - lead
for offset = 0, count - 1 do
local day = startDay + offset
local cell = { day = day, inMonth = true, isToday = false }
if day < 1 then
cell.day = prevMonthDays + day
cell.inMonth = false
cell.isToday = today.year == prevYear and today.month == prevMonth and today.day == cell.day
elseif day > currentMonthDays then
cell.day = day - currentMonthDays
cell.inMonth = false
local nextYear, nextMonth = normalizeMonth(year, month + 1)
cell.isToday = today.year == nextYear and today.month == nextMonth and today.day == cell.day
else
cell.isToday = today.year == year and today.month == month and today.day == day
end
table.insert(cells, cell)
end
local weeks = {}
for i = 1, #cells, 7 do
local week = {}
for j = i, i + 6 do
table.insert(week, cells[j])
end
table.insert(weeks, week)
end
return weeks
end
local function renderCalendarHtml(year, month)
local weeks = buildCalendarWeeks(year, month)
local weekRows = {}
for _, week in ipairs(weeks) do
local dayCells = {}
for _, cell in ipairs(week) do
local classes = { "day" }
if not cell.inMonth then
table.insert(classes, "outside")
end
if cell.isToday then
table.insert(classes, "today")
end
table.insert(dayCells, string.format(
"<div class=\"%s\">%2d</div>",
table.concat(classes, " "),
cell.day
))
end
table.insert(weekRows, string.format("<div class=\"week\">%s</div>", table.concat(dayCells, "")))
end
return string.format([[<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>
:root {
color-scheme: light dark;
}
html,
body {
margin: 0;
padding: 0;
width: 100%%;
height: 100%%;
overflow: hidden;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", "Roboto Mono", monospace;
background: transparent;
}
.panel {
position: absolute;
top: 8px;
left: 0;
right: 0;
bottom: 0;
z-index: 2;
box-sizing: border-box;
padding: 12px;
color: #ececec;
background: rgba(36, 36, 38, 0.92);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.34);
overflow: hidden;
}
.nub {
position: absolute;
top: 2px;
left: 50%%;
width: 10px;
height: 10px;
z-index: 3;
transform: translateX(-50%%) rotate(45deg);
background: rgba(36, 36, 38, 0.92);
border-left: 1px solid rgba(255, 255, 255, 0.14);
border-top: 1px solid rgba(255, 255, 255, 0.14);
}
.nub-bridge {
position: absolute;
top: 8px;
left: 50%%;
width: 16px;
height: 4px;
z-index: 4;
transform: translateX(-50%%);
background: rgba(36, 36, 38, 0.92);
}
.titlebar {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
margin-bottom: 8px;
}
.title {
font-size: 14px;
line-height: 20px;
text-align: center;
letter-spacing: 0.02em;
}
.controls-left,
.controls-right {
display: flex;
align-items: center;
gap: 6px;
}
.controls-right {
justify-content: flex-end;
}
.ctrl {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 20px;
padding: 0 6px;
border-radius: 6px;
color: #d9d9d9;
text-decoration: none;
font-size: 12px;
line-height: 20px;
}
.ctrl:hover {
background: rgba(255, 255, 255, 0.12);
}
.ctrl.arrow {
font-size: 14px;
}
.header,
.week {
display: grid;
grid-template-columns: repeat(7, 1fr);
column-gap: 4px;
}
.header {
margin-bottom: 4px;
color: #b9b9b9;
font-size: 12px;
line-height: 16px;
}
.header div,
.day {
text-align: center;
}
.week {
margin-bottom: 2px;
}
.day {
font-size: 14px;
line-height: 22px;
border-radius: 6px;
}
.outside {
color: #828282;
}
.today {
color: #111111;
background: #f0f0f0;
font-weight: 700;
}
</style>
</head>
<body>
<div class="nub"></div>
<div class="nub-bridge"></div>
<div class="panel">
<div class="titlebar">
<div class="controls-left">
<a class="ctrl arrow" href="minical://prev">&#8249;</a>
</div>
<div class="title">%s</div>
<div class="controls-right">
<!-- <a class="ctrl" href="minical://today">Today</a> -->
<a class="ctrl arrow" href="minical://next">&#8250;</a>
</div>
</div>
<div class="header">
<div>Su</div><div>Mo</div><div>Tu</div><div>We</div><div>Th</div><div>Fr</div><div>Sa</div>
</div>
%s
</div>
</body>
</html>]], monthTitle(year, month), table.concat(weekRows, ""))
end
local minical = _G.minical or {}
if minical.destroy then
minical.destroy()
end
local menu = hs.menubar.new(true, MENUBAR_AUTOSAVE_NAME)
local webview
local outsideClickTap
local viewYear
local viewMonth
local function pointInRect(point, rect)
return point.x >= rect.x and point.x <= rect.x + rect.w and point.y >= rect.y and point.y <= rect.y + rect.h
end
local function panelFrame()
local screenFrame = hs.screen.mainScreen():frame()
local menuFrame = menu:frame()
local x = screenFrame.x + screenFrame.w - PANEL_WIDTH - 8
local y = screenFrame.y + PANEL_TOP_MARGIN
if menuFrame then
x = menuFrame.x + (menuFrame.w / 2) - (PANEL_WIDTH / 2)
y = menuFrame.y + menuFrame.h + PANEL_TOP_MARGIN
end
if x < screenFrame.x + 8 then
x = screenFrame.x + 8
end
local maxX = screenFrame.x + screenFrame.w - PANEL_WIDTH - 8
if x > maxX then
x = maxX
end
return hs.geometry.rect(x, y, PANEL_WIDTH, PANEL_HEIGHT)
end
local function stopOutsideClickTap()
if outsideClickTap then
outsideClickTap:stop()
outsideClickTap = nil
end
end
local function hidePanel()
stopOutsideClickTap()
if webview and webview:isVisible() then
webview:hide()
end
end
local function setViewToCurrentMonth()
local now = os.date("*t")
viewYear = now.year
viewMonth = now.month
end
local function shiftViewMonth(delta)
viewYear, viewMonth = normalizeMonth(viewYear, viewMonth + delta)
end
local function refreshPanel()
if not webview then
return
end
webview:html(renderCalendarHtml(viewYear, viewMonth))
end
local function startOutsideClickTap()
stopOutsideClickTap()
outsideClickTap = hs.eventtap.new({
hs.eventtap.event.types.leftMouseDown,
hs.eventtap.event.types.rightMouseDown,
hs.eventtap.event.types.otherMouseDown,
}, function()
if not webview or not webview:isVisible() then
return false
end
local point = hs.mouse.absolutePosition()
local wvFrame = webview:frame()
if pointInRect(point, wvFrame) then
return false
end
local menuFrame = menu:frame()
if menuFrame and pointInRect(point, menuFrame) then
return false
end
hidePanel()
return false
end)
outsideClickTap:start()
end
local function ensureWebview()
if webview then
return
end
webview = hs.webview.new(panelFrame())
:allowTextEntry(false)
:transparent(true)
:policyCallback(function(action, _, details)
if action ~= "navigationAction" or not details or not details.request then
return true
end
local url = details.request.URL or details.request.url
if type(url) ~= "string" then
return true
end
if url == "minical://prev" then
shiftViewMonth(-1)
refreshPanel()
return false
end
if url == "minical://next" then
shiftViewMonth(1)
refreshPanel()
return false
end
if url == "minical://today" then
setViewToCurrentMonth()
refreshPanel()
return false
end
return true
end)
end
local function showPanel()
ensureWebview()
setViewToCurrentMonth()
webview:frame(panelFrame())
refreshPanel()
webview:show()
webview:bringToFront(true)
startOutsideClickTap()
end
local function togglePanel()
if webview and webview:isVisible() then
hidePanel()
else
showPanel()
end
end
menu:setTitle(MENUBAR_TITLE)
menu:autosaveName(MENUBAR_AUTOSAVE_NAME)
menu:setClickCallback(togglePanel)
function minical.destroy()
stopOutsideClickTap()
if webview then
webview:delete()
webview = nil
end
if menu then
menu:delete()
end
end
minical.menu = menu
minical.toggle = togglePanel
_G.minical = minical
return minical
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment