yal212/pomodoro.nvim

github github
workflowstats-tracking
stars 4
issues 0
subscribers 0
forks 1
CREATED

UPDATED


pomodoro.nvim

A focus-first Pomodoro timer for developers who live in Neovim.

Work / break cycles, editor-native notifications, per-day stats, an opt-in focus mode that mutes distractions while you ship.

Neovim Lua CI License: MIT Stars

Features · Focus mode · Install · Quickstart · Config · Commands · API · Recipes · FAQ


Starting a pomodoro, the pinned status window, a work→break transition, and the history float

Timer sped up for the demo. Reproduce it anytime: vhs scripts/demo.tape

✨ Features

  • 🍅 Classic Pomodoro cycles — 25 / 5 / 15 minute defaults, long break every 4th work block, all configurable
  • ⏱️ One-off durations:Pomodoro start 45 runs a single 45-minute block without touching your config
  • 🔔 Editor-native notificationsvim.notify (lights up nvim-notify / noice automatically) and/or a transient floating window
  • 📊 Per-day stats — completed work blocks, focused minutes, long-break count, persisted atomically as JSON
  • 📅 History & streaks:Pomodoro history floats the last N days; streaks track consecutive days hitting your goal
  • 🔉 Sound alerts (opt-in) — play any system command on phase end; sensible macOS default
  • 🪟 Toggleable status window — pinned, borderless card with phase-colored header, live progress bar, and today counter
  • 🛎️ Continue / stop prompt — when auto-start is off, each phase ends with a vim.ui.select asking whether to begin the next phase or stop
  • 🎯 Focus mode (opt-in) — block configured : commands during work; optionally mute diagnostics
  • 🧩 Renderer-agnostic statusline — drop-in component for lualine, heirline, or your own statusline
  • 🔭 Optional Telescope picker — last 30 days at a glance, only loaded if Telescope is present
  • 🪝 Hookson_work_start, on_break_start, on_cycle_complete, … wire your own behavior
  • 🪶 Zero required dependencies — pure Lua, stdlib only
  • Tested — 100+ plenary-busted specs, CI on stable + nightly Neovim

🎯 Focus mode

The feature this plugin exists for. A timer in your menu bar is easy to ignore — and it does nothing about the distraction that actually gets you, which is the editor itself. Focus mode makes a work block mean something.

Focus mode: :Lazy and :Mason blocked mid-work, diagnostics silenced, inactive window dimmed — then allowed again on the break

Timer sped up for the demo. Reproduce it anytime: vhs scripts/demo_focus.tape

While a work phase is running, focus mode can:

  • Block : commands you name — reach for :Lazy mid-flow and Neovim politely refuses
  • Silence diagnostics — no wall of red baiting you into a cleanup detour (signs stay, virtual text goes)
  • Dim inactive windows — attention stays on the buffer you're actually working in

The moment the break starts, everything comes back exactly as it was. It's opt-in and off by default:

require("pomodoro").setup({
  focus = {
    enabled            = true,
    blocked_commands   = { "Lazy", "Mason", "Telescope" },  -- your rabbit holes
    silent_diagnostics = true,
    dim_inactive       = true,
  },
})

📦 Requirements

  • Neovim ≥ 0.10
  • Optionaltelescope.nvim for the stats picker
  • Optionalnvim-notify for prettier toasts (any vim.notify replacement works)

📥 Installation

{
  "yal212/pomodoro.nvim",
  cmd = "Pomodoro",
  ---@type pomodoro.Config
  opts = {
    -- your config; see :help pomodoro-config
  },
}
use({
  "yal212/pomodoro.nvim",
  config = function()
    require("pomodoro").setup({})
  end,
})
Plug 'yal212/pomodoro.nvim'

" In your init.lua, after plug#end():
lua require("pomodoro").setup({})

🚀 Quickstart

:Pomodoro start          " 25-minute work block
:Pomodoro start 45       " one-off 45-minute work block
:Pomodoro skip           " end current phase, advance to next (doesn't count it)
:Pomodoro restart        " restart current phase from the beginning
:Pomodoro status         " toggle floating status window
:Pomodoro pause          " pause; remaining time preserved
:Pomodoro resume
:Pomodoro stop
:Pomodoro stats          " today + last 7 days + streak
:Pomodoro history        " last 14 days in a float (q/<Esc> closes)

Subcommands are case-insensitive — :Pomodoro Start works too — and <Tab> completes them.

No keys are bound by default. Every action ships a <Plug> mapping — <Plug>(PomodoroStart), (PomodoroPause), (PomodoroResume), (PomodoroStop), (PomodoroSkip), (PomodoroRestart), (PomodoroStatus), (PomodoroStats), (PomodoroHistory) — so suggested keymaps look like:

local map = vim.keymap.set
map("n", "<leader>ps", "<Plug>(PomodoroStart)",  { desc = "Pomodoro: start" })
map("n", "<leader>pp", "<Plug>(PomodoroPause)",  { desc = "Pomodoro: pause" })
map("n", "<leader>pr", "<Plug>(PomodoroResume)", { desc = "Pomodoro: resume" })
map("n", "<leader>px", "<Plug>(PomodoroStop)",   { desc = "Pomodoro: stop" })
map("n", "<leader>pw", "<Plug>(PomodoroStatus)", { desc = "Pomodoro: window" })
map("n", "<leader>pS", "<Plug>(PomodoroStats)",  { desc = "Pomodoro: stats" })

With lazy.nvim, putting them in keys also lazy-loads the plugin on first press:

{
  "yal212/pomodoro.nvim",
  keys = {
    { "<leader>ps", "<Plug>(PomodoroStart)",  desc = "Pomodoro: start" },
    { "<leader>pw", "<Plug>(PomodoroStatus)", desc = "Pomodoro: window" },
  },
}

Plain <cmd>Pomodoro start<cr> mappings work just as well if you prefer them.

⚙️ Configuration

setup() is not required — :Pomodoro initializes itself with defaults on first use. Pass any subset of the table below to override. Options are validated: an invalid value (say status_window.anchor = "TOP", or a non-numeric width) makes setup() fail immediately with a clear pomodoro:-prefixed error instead of surfacing later as a raw runtime error.

require("pomodoro").setup({
  -- Phase durations (minutes)
  durations = {
    work        = 25,
    short_break = 5,
    long_break  = 15,
  },

  -- Long break every Nth completed work block
  cycles_per_long_break = 4,

  -- Target work blocks per day (0 = disabled)
  daily_goal = 0,

  -- Phase transition behavior
  auto_start_break = true,   -- break begins immediately; if false a Continue/Stop prompt appears
  auto_start_work  = false,  -- next work block requires :Pomodoro start (or Continue from prompt)

  -- Notification channels (any subset, in display order)
  notify_styles = { "vim_notify", "float" },
  notify = {
    float_duration_ms = 4000,
  },

  -- Opt-in sound on phase end (not played on skip)
  sound = {
    enabled = false,
    cmd     = nil,  -- string (run via sh -c) or argv table, e.g. { "paplay", "/path/ding.wav" }
                    -- nil → afplay with a system sound on macOS
  },

  -- Statusline component appearance
  statusline = {
    icon            = "",
    show_when_idle  = false,
    format          = "%s %s",     -- icon, body
    refresh_ms      = 250,         -- live tick while a phase is running
    condition       = nil,         -- function(ctx) return boolean end
  },

  -- Toggleable pinned status window (borderless card)
  status_window = {
    border             = "none",
    width              = 36,
    height             = 5,
    anchor             = "NE",
    row                = 1,
    col_offset         = 2,
    refresh_ms         = 250,
    show_progress_bar  = true,
    show_today         = true,
    title              = nil,      -- optional float title, e.g. " pomodoro "
    title_pos          = "center", -- "left" | "center" | "right"
    icons = {
      work        = "▶",
      short_break = "•",
      long_break  = "★",
      paused      = "❚❚",
      idle        = "○",
    },
  },

  -- Opt-in focus enforcement
  focus = {
    enabled            = false,
    blocked_commands   = {},  -- e.g. { "Lazy", "Mason", "Telescope" }
    silent_diagnostics = false,
    dim_inactive       = false,
  },

  -- JSON stats on disk
  persistence = {
    enabled = true,
    path    = nil,            -- nil → vim.fn.stdpath('data') .. '/pomodoro/stats.json'
  },

  -- Lifecycle hooks
  hooks = {
    on_work_start     = nil,  -- function(payload) end
    on_work_end       = nil,
    on_break_start    = nil,
    on_break_end      = nil,
    on_cycle_complete = nil,
  },
})

📋 Commands

Everything lives under a single :Pomodoro {subcommand} command. Subcommands are case-insensitive (:Pomodoro Start == :Pomodoro start) and <Tab>-completable.

Subcommand Args Description
:Pomodoro start [work|short|long|{minutes}] Start a phase. Defaults to next in cycle, or resumes if paused. A number starts a one-off work block of that length.
:Pomodoro pause Pause the active phase, preserving remaining time.
:Pomodoro resume Resume a paused phase.
:Pomodoro stop Stop and reset to idle.
:Pomodoro skip End the current phase immediately and advance. Skipped work blocks are not counted in stats or hooks.
:Pomodoro restart Restart the current phase from the beginning.
:Pomodoro status Toggle the floating status window.
:Pomodoro stats Show today + last 7 days summary and current streak.
:Pomodoro history [{days}] Float the last N days (default 14). Close with q or <Esc>.
:Pomodoro reset Wipe persisted stats (with confirm prompt).

🧰 Lua API

local pomo = require("pomodoro")

pomo.setup({})                           -- merge config (idempotent)
pomo.start("work" | "short" | "long" | nil)
pomo.start(45)                           -- one-off 45-minute work block
pomo.pause()
pomo.resume()
pomo.stop()
pomo.skip()
pomo.restart()
pomo.status()                            -- toggle status window
pomo.stats_summary()                     -- print today + week + streak via vim.notify
pomo.history(14)                         -- float the last 14 days
pomo.reset_stats()
pomo.statusline()                        -- string for your statusline

-- Lower level
require("pomodoro.statusline").component()         -- string
require("pomodoro.statusline").component_lualine() -- { text, hl }
require("pomodoro.stats").today()                  -- table
require("pomodoro.stats").last_n_days(7)           -- table[]
require("pomodoro.stats").streak(goal)             -- consecutive days meeting goal

🍳 Recipes

require("lualine").setup({
  sections = {
    lualine_x = {
      function() return require("pomodoro.statusline").component() end,
      "encoding", "fileformat", "filetype",
    },
  },
})
local function pomo()
  local s = require("pomodoro.statusline").component_lualine()
  if s.text == "" then return "" end
  return "%#" .. s.hl .. "#" .. s.text
end

require("lualine").setup({
  sections = { lualine_x = { pomo, "filetype" } },
})
vim.o.statusline = "%f %m %= %{v:lua.require('pomodoro').statusline()} "
require("pomodoro").setup({
  hooks = {
    on_break_start = function(p)
      vim.fn.jobstart({
        "terminal-notifier",
        "-title", "Pomodoro",
        "-message", "Break time — " .. p.duration_min .. " min",
        "-sound", "Glass",
      })
    end,
  },
})
require("pomodoro").setup({
  hooks = {
    on_break_start = function(p)
      vim.fn.jobstart({ "notify-send", "Pomodoro", "Break — " .. p.duration_min .. " min" })
    end,
  },
})
require("pomodoro").setup({
  focus = {
    enabled = true,
    blocked_commands   = { "Lazy", "Mason", "Telescope" },
    silent_diagnostics = true,
    dim_inactive       = true,  -- dim non-current windows during work
  },
})
require("pomodoro").setup({
  sound = { enabled = true },                              -- macOS: system sound via afplay
  -- sound = { enabled = true, cmd = { "paplay", "/usr/share/sounds/freedesktop/stereo/complete.oga" } },  -- Linux
})

🔭 Telescope

If nvim-telescope/telescope.nvim is installed, an extension is registered automatically:

:Telescope pomodoro stats

Last 30 days; preview pane shows that day's breakdown (work blocks, long breaks, minutes focused).

🎨 Highlights

All groups are defined with default = true links, so your colorscheme or config can override them freely:

Group Default link Used for
PomodoroWork DiagnosticWarn Work phase header / statusline
PomodoroBreak DiagnosticOk Break phase header / statusline
PomodoroPaused DiagnosticHint Paused state
PomodoroIdle Comment Idle state
PomodoroProgress DiagnosticInfo Progress bar fill
PomodoroProgressTrack NonText Progress bar track
PomodoroDim Comment Muted text in the status window
PomodoroDimNC Comment Inactive windows when focus.dim_inactive is on
PomodoroTitle FloatTitle Float titles
vim.api.nvim_set_hl(0, "PomodoroWork", { fg = "#ff9e64", bold = true })

🩺 Health

:checkhealth pomodoro

Reports Neovim version, data-dir writability, sound-command availability (when enabled), and which optional integrations are available.

❓ FAQ

No — only daily stats are persisted. Quitting Neovim mid-phase discards the running timer; the next launch starts idle, with your stats intact.

Timers are driven by libuv, which stalls while the system is suspended, so a phase is effectively extended by however long the machine slept. Treat a work block as "time Neovim was actually awake".

Yes. They share one stats file, and every save is atomic (temp file + rename), so nothing corrupts. Each save also re-reads the file and merges that instance's new counts on top, so same-day counts from parallel instances add up — completing blocks in two Neovims at once records all of them.

require("pomodoro").setup({
  persistence = { enabled = false },  -- keep stats in memory only
  -- or: persistence = { path = vim.fn.expand("~/notes/pomodoro.json") },
})

🤝 Contributing

Issues and PRs welcome — see CONTRIBUTING.md for dev setup, test commands, and guidelines.

🙏 Acknowledgements

📄 License

MIT © yal212