A generic, data-source-agnostic calendar/agenda UI for Neovim: month, week, and day views in a reusable sidebar window, styled after snacks.nvim's public-API conventions (Snacks.win/Snacks.picker). almanac.nvim itself knows nothing about where events come from — you hand it an EventProvider and it renders whatever it's given.
Its first consumer is outlook.nvim (an Outlook COM bridge), but almanac.nvim has no dependency on it or on any specific data source — Google Calendar, org-mode, a local .ics file, anything that can produce a list of {id, title, start, ...} events works.
Status: v1 implemented (month/week/day views, sidebar window management, position/view cycling, edgy.nvim-aware). Not yet tagged/versioned for wider distribution. See docs/DESIGN.md for the full API/IF spec and design rationale.
Left sidebar, month view (<CR> on a day shows its agenda below the grid; • marks a day with events):
[Month]
« July 2026 »
Mo Tu We Th Fr Sa Su
29 30 1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24• 25• 26
27 28 29 30 31 1 2
Fri, Jul 24 2026
08:16 Team sync
09:16 1:1 with Sam
Week view (gw) — one row per day, events listed underneath:
[Week]
« Jul 20 - 26 »
Mon 20
Tue 21
Wed 22
Thu 23
Fri 24
08:16 Team sync
09:16 1:1 with Sam
Sat 25
08:16 Design review
Sun 26
Day view (gd) — hourly time-axis:
[Day]
« Fri, Jul 24 2026 »
00:00 -------------
...
07:00 -------------
08:00 -- Team sync
| Location: Zoom
09:00 -- 1:1 with Sam
10:00 -------------
...
23:00 -------------
- Three views — month (traditional grid), week (7-day agenda), day (hourly time-axis) — sharing one
Eventdata model andEventProvidercontract. Switch withgm/gw/gdor cycle with<Tab>. - Sidebar window, nvim-tree/neo-tree style: left/right/top/bottom/float, reused across opens (not a floating popup, not a takeover of your current window). Cycle position with
<C-w><C-w>. - Any data source:
eventscan be a staticEvent[]table or an asyncfunction(range, cb)— mirrorssnacks.picker's finder contract (called once per visible-range change). - edgy.nvim aware: if present (e.g. via LazyVim's
:LazyExtras→ui.edgy), almanac's own position-cycling steps aside and lets edgy coordinate the sidebar alongside your other edgebars (neo-tree, Trouble, etc.) — see below for the copy-paste stanza. - English-only UI text, for global use; only your own event data (titles, locations) passes through untranslated.
- All highlight groups are linked (
default = true), never hardcoded, so any colorscheme applies automatically.
- Neovim >= 0.10
-- lua/plugins/almanac.lua
return {
"tya5/almanac.nvim",
}almanac.nvim is a library, not a standalone command-driven plugin — you construct and drive a Calendar instance yourself (see Usage), typically from another plugin or a small wrapper in your own config.
local Almanac = require("almanac")
local cal = Almanac({
events = {
{ id = "1", title = "Team sync", start = os.time() + 3600 },
},
})
cal:show()Async data source (called once per visible range; see docs/DESIGN.md 3.2):
local cal = Almanac({
events = function(range, cb)
my_backend.fetch(range.from, range.to, function(events)
cb(events)
end)
end,
})React to selection:
cal:on("event_selected", function(_, event)
print("selected: " .. event.title)
end)
cal:on("day_selected", function(_, epoch)
print(os.date("%Y-%m-%d", epoch))
end)See docs/DESIGN.md section 3 for the full spec. Summary:
cal:show() / cal:close() / cal:toggle()
cal:next() cal:prev() -- page by the *current view's* unit: month/week/day (default <C-f>/<C-b>)
cal:next_day() cal:prev_day() -- move focus by a day, regardless of view
cal:next_week() cal:prev_week() -- move focus by a week
cal:next_month() cal:prev_month() -- jumps to day 1 of the target month
cal:move_down() cal:move_up() -- move focus to the next/previous *rendered* line (day cell, agenda day, or event); pages only past the rendered edge (default j/k)
cal:move_left() cal:move_right() -- move focus within the current grid row (month view); no-op on single-item rows (default h/l)
cal:next_event() cal:prev_event() -- jump directly between event lines in the current render, no paging, wraps at the edges (default ]e/[e)
cal:goto_date(epoch) cal:today() cal:refresh()
cal:set_view("month"|"week"|"day") cal:cycle_view()
cal:set_position("left"|"right"|"top"|"bottom"|"float") cal:cycle_position()
cal:selected_day() cal:selected_events() cal:focused_event()
cal:on(event, callback) -- range_changed, view_changed, day_selected, event_selected, position_changed, close
cal:map(lhs, action) -- same shapes as opts.keysrequire("almanac")({
date = os.time(), -- initial focused day; default: today
view = "month", -- "month" | "week" | "day"
week_start = "monday", -- "sunday" | "monday"
position = "left", -- "left" | "right" | "top" | "bottom" | "float"
size = 30, -- columns (left/right) or rows (top/bottom); <=1 is a fraction
manage_position = "auto", -- "auto": defer to edgy.nvim if present | "always": always self-manage
events = nil, -- Event[] or function(range, cb) — see docs/DESIGN.md 3.2
keys = { --[[ see docs/DESIGN.md 3.4 for the full default table ]] },
wo = {}, bo = {}, -- window-/buffer-local options
on_open = nil, on_close = nil,
})require("almanac").setup(opts) optionally changes the global defaults picked up by every subsequent Almanac(opts) call — not required, each call can pass its own opts instead.
| Key | Action |
|---|---|
h/l |
move focus within the current row's cells (month view's grid); no-op on rows with only one item (week/day view) |
j/k |
move focus to the next/previous rendered line — a grid row, an agenda day label, or an event line, whichever is actually there |
]e/[e |
jump directly to the next/previous event line in the current render only (no paging; wraps at the first/last event) |
<C-f>/<C-b> |
page by the current view's unit (month in month view, week in week view, day in day view) |
gt |
today |
gm/gw/gd |
switch to month/week/day view |
<Tab> |
cycle view (month → week → day) |
<CR> |
select day/event under cursor (day_selected/event_selected) |
<C-w><C-w> |
cycle sidebar position (no-op if edgy.nvim is managing it) |
q |
close |
"Focus" is self.date (plus self:focused_event() when focus has landed on a specific event) — the day/event the calendar considers currently selected. The real Neovim cursor is kept in sync with it after every render, so h/j/k/l feel like moving a cursor through the view rather than the text silently changing under a stationary cursor.
h/j/k/l are screen-driven: each keypress moves focus to whatever is actually rendered next to the cursor right now (via the renderer's line_map), never by computing a new date first and deriving a screen position from it. Date arithmetic (next_day/next_week/etc.) only runs as a fallback once navigation reaches the edge of what's currently drawn — e.g. j on the last rendered line pages to the next week/month/day and lands wherever that page's own render puts focus. This is also why month view's grid flows straight into its trailing per-day agenda with no special-casing, and why week/day view's j/k naturally stop on event lines instead of always moving exactly one day.
If you use edgy.nvim (bundled as a LazyVim extra: :LazyExtras → ui.edgy), add a stanza like this so almanac's sidebar is coordinated alongside your other edgebars:
-- in your edgy.nvim opts
{
"folke/edgy.nvim",
opts = {
left = {
{ ft = "almanac", title = "Calendar", size = { width = 30 }, pinned = true },
},
},
}With edgy.nvim present, almanac's own cycle_position() becomes a no-op by default (opts.manage_position = "auto") so the two don't fight over the same window — position management is edgy's job. Set manage_position = "always" to keep using almanac's own cycling regardless.
:checkhealth almanac- docs/DESIGN.md — full API/IF spec, data model, rendering approach, ecosystem integration research (edgy.nvim, prior art), roadmap
- tests/README.md — how to run the test suite
Lua formatting uses StyLua (stylua.toml); run stylua . before committing. See tests/README.md for running tests.