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.
This commit is contained in:
parent
ee2d125846
commit
66eb93a6d1
4 changed files with 157 additions and 4 deletions
|
|
@ -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*
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue