From 66eb93a6d1ba349916b4a04b21eb783d27564793 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 25 Feb 2026 20:34:51 -0500 Subject: [PATCH] feat(parse): flexible time parsing for @ suffix Problem: the @HH:MM time suffix required zero-padded 24-hour format, forcing users to write due:tomorrow@14:00 instead of due:tomorrow@2pm. Solution: add normalize_time() that accepts bare hours (9, 14), H:MM (9:30), am/pm (2pm, 9:30am, 12am), and existing HH:MM format, normalizing all to canonical HH:MM on save. --- doc/pending.txt | 30 +++++++++++++++++++ lua/pending/init.lua | 3 +- lua/pending/parse.lua | 59 ++++++++++++++++++++++++++++++++++-- spec/parse_spec.lua | 69 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 157 insertions(+), 4 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index 29b7e38..aad924c 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -149,6 +149,23 @@ token, the `D` prompt, and `:Pending add`. `soy` / `eoy` January 1 / December 31 of current year `later` / `someday` Sentinel date (default: `9999-12-30`) +Time suffix: ~ *pending-dates-time* +Any named date or absolute date accepts an `@` time suffix. Supported +formats: `HH:MM` (24h), `H:MM`, bare hour (`9`, `14`), and am/pm +(`2pm`, `9:30am`, `12am`). All forms are normalized to `HH:MM` on save. > + + due:tomorrow@2pm " tomorrow at 14:00 + due:fri@9 " next Friday at 09:00 + due:+1w@17:00 " one week from today at 17:00 + due:tomorrow@9:30am " tomorrow at 09:30 + due:2026-03-15@08:00 " absolute date with time + due:2026-03-15T14:30 " ISO 8601 datetime (also accepted) +< + +Tasks with a time component are not considered overdue until after the +specified time. The time is displayed alongside the date in virtual text +and preserved across recurrence advances. + ============================================================================== RECURRENCE *pending-recurrence* @@ -417,6 +434,19 @@ Fields: ~ |pending.GcalConfig|. Omit this field entirely to disable Google Calendar sync. +============================================================================== +RECIPES *pending-recipes* + +Configure blink.cmp to use pending.nvim's omnifunc as a completion source: >lua + require('blink.cmp').setup({ + sources = { + per_filetype = { + pending = { 'omni', 'buffer' }, + }, + }, + }) +< + ============================================================================== GOOGLE CALENDAR *pending-gcal* diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 9dcdc45..631c0e3 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -1,5 +1,4 @@ local buffer = require('pending.buffer') -local config = require('pending.config') local diff = require('pending.diff') local parse = require('pending.parse') local store = require('pending.store') @@ -211,7 +210,7 @@ function M.prompt_date() if not id then return end - vim.ui.input({ prompt = 'Due date (YYYY-MM-DD[Thh:mm]): ' }, function(input) + vim.ui.input({ prompt = 'Due date (today, +3d, fri@2pm, etc.): ' }, function(input) if not input then return end diff --git a/lua/pending/parse.lua b/lua/pending/parse.lua index 73de18f..e234269 100644 --- a/lua/pending/parse.lua +++ b/lua/pending/parse.lua @@ -36,6 +36,60 @@ local function is_valid_time(s) return hn >= 0 and hn <= 23 and mn >= 0 and mn <= 59 end +---@param s string +---@return string|nil +local function normalize_time(s) + local h, m, period + + h, m, period = s:match('^(%d+):(%d%d)([ap]m)$') + if not h then + h, period = s:match('^(%d+)([ap]m)$') + if h then + m = '00' + end + end + if not h then + h, m = s:match('^(%d%d):(%d%d)$') + end + if not h then + h, m = s:match('^(%d):(%d%d)$') + end + if not h then + h = s:match('^(%d+)$') + if h then + m = '00' + end + end + + if not h then + return nil + end + + local hn = tonumber(h) --[[@as integer]] + local mn = tonumber(m) --[[@as integer]] + + if period then + if hn < 1 or hn > 12 then + return nil + end + if period == 'am' then + hn = hn == 12 and 0 or hn + else + hn = hn == 12 and 12 or hn + 12 + end + else + if hn < 0 or hn > 23 then + return nil + end + end + + if mn < 0 or mn > 59 then + return nil + end + + return string.format('%02d:%02d', hn, mn) +end + ---@param s string ---@return boolean local function is_valid_datetime(s) @@ -100,9 +154,10 @@ end ---@param text string ---@return string|nil function M.resolve_date(text) - local date_input, time_suffix = text:match('^(.+)@(%d%d:%d%d)$') + local date_input, time_suffix = text:match('^(.+)@(.+)$') if time_suffix then - if not is_valid_time(time_suffix) then + time_suffix = normalize_time(time_suffix) + if not time_suffix then return nil end else diff --git a/spec/parse_spec.lua b/spec/parse_spec.lua index edeffcd..fd36d68 100644 --- a/spec/parse_spec.lua +++ b/spec/parse_spec.lua @@ -323,6 +323,75 @@ describe('parse', function() end) end) + describe('resolve_date with time suffix', function() + local today = os.date('*t') --[[@as osdate]] + local tomorrow_str = os.date( + '%Y-%m-%d', + os.time({ year = today.year, month = today.month, day = today.day + 1 }) + ) --[[@as string]] + + it('resolves bare hour to T09:00', function() + local result = parse.resolve_date('tomorrow@9') + assert.are.equal(tomorrow_str .. 'T09:00', result) + end) + + it('resolves bare military hour to T14:00', function() + local result = parse.resolve_date('tomorrow@14') + assert.are.equal(tomorrow_str .. 'T14:00', result) + end) + + it('resolves H:MM to T09:30', function() + local result = parse.resolve_date('tomorrow@9:30') + assert.are.equal(tomorrow_str .. 'T09:30', result) + end) + + it('resolves HH:MM (existing format) to T09:30', function() + local result = parse.resolve_date('tomorrow@09:30') + assert.are.equal(tomorrow_str .. 'T09:30', result) + end) + + it('resolves 2pm to T14:00', function() + local result = parse.resolve_date('tomorrow@2pm') + assert.are.equal(tomorrow_str .. 'T14:00', result) + end) + + it('resolves 9am to T09:00', function() + local result = parse.resolve_date('tomorrow@9am') + assert.are.equal(tomorrow_str .. 'T09:00', result) + end) + + it('resolves 9:30pm to T21:30', function() + local result = parse.resolve_date('tomorrow@9:30pm') + assert.are.equal(tomorrow_str .. 'T21:30', result) + end) + + it('resolves 12am to T00:00', function() + local result = parse.resolve_date('tomorrow@12am') + assert.are.equal(tomorrow_str .. 'T00:00', result) + end) + + it('resolves 12pm to T12:00', function() + local result = parse.resolve_date('tomorrow@12pm') + assert.are.equal(tomorrow_str .. 'T12:00', result) + end) + + it('rejects hour 24', function() + assert.is_nil(parse.resolve_date('tomorrow@24')) + end) + + it('rejects 13am', function() + assert.is_nil(parse.resolve_date('tomorrow@13am')) + end) + + it('rejects minute 60', function() + assert.is_nil(parse.resolve_date('tomorrow@9:60')) + end) + + it('rejects alphabetic garbage', function() + assert.is_nil(parse.resolve_date('tomorrow@abc')) + end) + end) + describe('command_add', function() it('parses simple text', function() local desc, meta = parse.command_add('Buy milk')