na 1.2.86 → 1.2.87
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.cursor/commands/changelog.md +4 -0
- data/.rubocop_todo.yml +22 -17
- data/CHANGELOG.md +58 -1
- data/Gemfile +7 -1
- data/Gemfile.lock +37 -1
- data/README.md +66 -2
- data/Rakefile +78 -78
- data/bin/commands/add.rb +31 -1
- data/bin/commands/changes.rb +1 -0
- data/bin/commands/complete.rb +11 -0
- data/bin/commands/find.rb +9 -1
- data/bin/commands/next.rb +35 -2
- data/bin/commands/tagged.rb +91 -58
- data/bin/commands/update.rb +36 -4
- data/bin/na +6 -0
- data/lib/na/action.rb +26 -3
- data/lib/na/actions.rb +136 -6
- data/lib/na/next_action.rb +88 -31
- data/lib/na/string.rb +9 -2
- data/lib/na/theme.rb +1 -0
- data/lib/na/types.rb +190 -0
- data/lib/na/version.rb +1 -1
- data/lib/na.rb +1 -0
- data/src/_README.md +44 -1
- metadata +3 -1
data/lib/na/next_action.rb
CHANGED
|
@@ -327,7 +327,22 @@ module NA
|
|
|
327
327
|
move: nil,
|
|
328
328
|
remove_tag: [],
|
|
329
329
|
replace: nil,
|
|
330
|
-
tagged: nil
|
|
330
|
+
tagged: nil,
|
|
331
|
+
started_at: nil,
|
|
332
|
+
done_at: nil,
|
|
333
|
+
duration_seconds: nil)
|
|
334
|
+
# Coerce date/time inputs if passed as strings
|
|
335
|
+
begin
|
|
336
|
+
started_at = NA::Types.parse_date_begin(started_at) if started_at && !started_at.is_a?(Time)
|
|
337
|
+
rescue StandardError
|
|
338
|
+
# leave as-is
|
|
339
|
+
end
|
|
340
|
+
begin
|
|
341
|
+
done_at = NA::Types.parse_date_end(done_at) if done_at && !done_at.is_a?(Time)
|
|
342
|
+
rescue StandardError
|
|
343
|
+
# leave as-is
|
|
344
|
+
end
|
|
345
|
+
NA.notify("UPDATE parsed started_at=#{started_at.inspect} done_at=#{done_at.inspect} duration=#{duration_seconds.inspect}", debug: true)
|
|
331
346
|
# Expand target to absolute path to avoid path resolution issues
|
|
332
347
|
target = File.expand_path(target) unless Pathname.new(target).absolute?
|
|
333
348
|
|
|
@@ -359,12 +374,20 @@ module NA
|
|
|
359
374
|
# So we don't need to handle it here - the action is already edited
|
|
360
375
|
|
|
361
376
|
add_tag ||= []
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
377
|
+
NA.notify("PROCESS before add.process started_at=#{started_at.inspect} done_at=#{done_at.inspect}", debug: true)
|
|
378
|
+
add.process(priority: priority,
|
|
379
|
+
finish: finish,
|
|
380
|
+
add_tag: add_tag,
|
|
381
|
+
remove_tag: remove_tag,
|
|
382
|
+
started_at: started_at,
|
|
383
|
+
done_at: done_at,
|
|
384
|
+
duration_seconds: duration_seconds)
|
|
385
|
+
NA.notify("PROCESS after add.process action=\"#{add.action}\"", debug: true)
|
|
386
|
+
|
|
387
|
+
# Remove the original action and its notes if this is an existing action
|
|
365
388
|
action_line = add.file_line
|
|
366
389
|
note_lines = add.note.is_a?(Array) ? add.note.count : 0
|
|
367
|
-
contents.slice!(action_line, note_lines + 1)
|
|
390
|
+
contents.slice!(action_line, note_lines + 1) if action_line.is_a?(Integer)
|
|
368
391
|
|
|
369
392
|
# Prepare updated note
|
|
370
393
|
note = note.to_s.split("\n") unless note.is_a?(Array)
|
|
@@ -384,32 +407,52 @@ module NA
|
|
|
384
407
|
# Format note for insertion
|
|
385
408
|
note_str = updated_note.empty? ? '' : "\n#{indent}\t\t#{updated_note.join("\n#{indent}\t\t").strip}"
|
|
386
409
|
|
|
387
|
-
#
|
|
388
|
-
if
|
|
389
|
-
|
|
390
|
-
# End of project
|
|
391
|
-
target_proj.last_line + 1
|
|
392
|
-
else
|
|
393
|
-
# Start of project (after project header)
|
|
394
|
-
target_proj.line + 1
|
|
395
|
-
end
|
|
396
|
-
contents.insert(insert_line, "#{indent}\t- #{add.action}#{note_str}")
|
|
410
|
+
# If delete was requested in this direct update path, do not re-insert
|
|
411
|
+
if delete
|
|
412
|
+
affected_actions << { action: add, desc: 'deleted' }
|
|
397
413
|
else
|
|
398
|
-
#
|
|
399
|
-
|
|
400
|
-
|
|
414
|
+
# Insert at correct location
|
|
415
|
+
if target_proj
|
|
416
|
+
insert_line = if append
|
|
417
|
+
# End of project
|
|
418
|
+
target_proj.last_line + 1
|
|
419
|
+
else
|
|
420
|
+
# Start of project (after project header)
|
|
421
|
+
target_proj.line + 1
|
|
422
|
+
end
|
|
423
|
+
# Ensure @started tag persists if provided
|
|
424
|
+
final_action = add.action.dup
|
|
425
|
+
if started_at && final_action !~ /(?<=\A| )@start(?:ed)?\(/i
|
|
426
|
+
final_action = final_action.gsub(/(?<=\A| )@start(?:ed)?\(.*?\)/i, '').strip
|
|
427
|
+
final_action = "#{final_action} @started(#{started_at.strftime('%Y-%m-%d %H:%M')})"
|
|
428
|
+
end
|
|
429
|
+
NA.notify("INSERT at #{insert_line} final_action=\"#{final_action}\"", debug: true)
|
|
430
|
+
contents.insert(insert_line, "#{indent}\t- #{final_action}#{note_str}")
|
|
431
|
+
else
|
|
432
|
+
# Fallback: append to end of file
|
|
433
|
+
final_action = add.action.dup
|
|
434
|
+
if started_at && final_action !~ /(?<=\A| )@start(?:ed)?\(/i
|
|
435
|
+
final_action = final_action.gsub(/(?<=\A| )@start(?:ed)?\(.*?\)/i, '').strip
|
|
436
|
+
final_action = "#{final_action} @started(#{started_at.strftime('%Y-%m-%d %H:%M')})"
|
|
437
|
+
end
|
|
438
|
+
NA.notify("APPEND final_action=\"#{final_action}\"", debug: true)
|
|
439
|
+
contents << "#{indent}\t- #{final_action}#{note_str}"
|
|
440
|
+
end
|
|
401
441
|
|
|
402
|
-
|
|
442
|
+
notify(add.pretty)
|
|
443
|
+
end
|
|
403
444
|
|
|
404
445
|
# Track affected action and description
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
446
|
+
unless delete
|
|
447
|
+
changes = ['updated']
|
|
448
|
+
changes << 'finished' if finish
|
|
449
|
+
changes << "priority=#{priority}" if priority.to_i.positive?
|
|
450
|
+
changes << "tags+#{add_tag.join(',')}" unless add_tag.nil? || add_tag.empty?
|
|
451
|
+
changes << "tags-#{remove_tag.join(',')}" unless remove_tag.nil? || remove_tag.empty?
|
|
452
|
+
changes << 'note updated' unless note.nil? || note.empty?
|
|
453
|
+
changes << "moved to #{target_proj.project}" if move && target_proj
|
|
454
|
+
affected_actions << { action: add, desc: changes.join(', ') }
|
|
455
|
+
end
|
|
413
456
|
else
|
|
414
457
|
# Check if search is actually target_line
|
|
415
458
|
target_line = search.is_a?(Hash) && search[:target_line] ? search[:target_line] : nil
|
|
@@ -445,7 +488,13 @@ module NA
|
|
|
445
488
|
# If replace is defined, use search to search and replace text in action
|
|
446
489
|
action.action.sub!(Regexp.new(Regexp.escape(search), Regexp::IGNORECASE), replace) if replace
|
|
447
490
|
|
|
448
|
-
action.process(priority: priority,
|
|
491
|
+
action.process(priority: priority,
|
|
492
|
+
finish: finish,
|
|
493
|
+
add_tag: add_tag,
|
|
494
|
+
remove_tag: remove_tag,
|
|
495
|
+
started_at: started_at,
|
|
496
|
+
done_at: done_at,
|
|
497
|
+
duration_seconds: duration_seconds)
|
|
449
498
|
|
|
450
499
|
target_proj = if target_proj
|
|
451
500
|
projects.select { |proj| proj.project =~ /^#{target_proj.project}$/ }.first
|
|
@@ -532,7 +581,7 @@ module NA
|
|
|
532
581
|
# @param finish [Boolean] Mark as finished
|
|
533
582
|
# @param append [Boolean] Append to project
|
|
534
583
|
# @return [void]
|
|
535
|
-
def add_action(file, project, action, note = [], priority: 0, finish: false, append: false)
|
|
584
|
+
def add_action(file, project, action, note = [], priority: 0, finish: false, append: false, started_at: nil, done_at: nil, duration_seconds: nil)
|
|
536
585
|
parent = project.split(%r{[:/]})
|
|
537
586
|
|
|
538
587
|
if NA.global_file
|
|
@@ -545,8 +594,16 @@ module NA
|
|
|
545
594
|
|
|
546
595
|
action = Action.new(file, project, parent, action, nil, note)
|
|
547
596
|
|
|
548
|
-
update_action(file, nil,
|
|
549
|
-
|
|
597
|
+
update_action(file, nil,
|
|
598
|
+
add: action,
|
|
599
|
+
project: project,
|
|
600
|
+
add_tag: add_tag,
|
|
601
|
+
priority: priority,
|
|
602
|
+
finish: finish,
|
|
603
|
+
append: append,
|
|
604
|
+
started_at: started_at,
|
|
605
|
+
done_at: done_at,
|
|
606
|
+
duration_seconds: duration_seconds)
|
|
550
607
|
end
|
|
551
608
|
|
|
552
609
|
# Build a nested hash representing project hierarchy from actions
|
data/lib/na/string.rb
CHANGED
|
@@ -189,7 +189,7 @@ class ::String
|
|
|
189
189
|
# @param indent [Integer] Number of spaces to indent each line
|
|
190
190
|
# @return [String] Wrapped string
|
|
191
191
|
def wrap(width, indent)
|
|
192
|
-
return
|
|
192
|
+
return to_s if width.nil? || width <= 0
|
|
193
193
|
|
|
194
194
|
output = []
|
|
195
195
|
line = []
|
|
@@ -310,7 +310,14 @@ class ::String
|
|
|
310
310
|
m = Regexp.last_match
|
|
311
311
|
t = m['tag']
|
|
312
312
|
d = m['date']
|
|
313
|
-
|
|
313
|
+
# Determine whether to bias toward future or past parsing
|
|
314
|
+
# Non-done tags usually bias to future, except explicit past phrases like "ago", "yesterday", or "last ..."
|
|
315
|
+
explicit_past = d =~ /(\bago\b|yesterday|\blast\b)/i
|
|
316
|
+
future = if t =~ /^(done|complete)/
|
|
317
|
+
false
|
|
318
|
+
else
|
|
319
|
+
explicit_past ? false : true
|
|
320
|
+
end
|
|
314
321
|
parsed_date = d =~ iso_rx ? Time.parse(d) : d.chronify(guess: :begin, future: future)
|
|
315
322
|
parsed_date.nil? ? m[0] : "@#{t}(#{parsed_date.strftime('%F %R')})"
|
|
316
323
|
end
|
data/lib/na/theme.rb
CHANGED
data/lib/na/types.rb
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'na/string'
|
|
4
|
+
|
|
5
|
+
module NA
|
|
6
|
+
# Custom types for GLI
|
|
7
|
+
# Provides natural language date/time and duration parsing
|
|
8
|
+
# Uses chronify gem for parsing
|
|
9
|
+
module Types
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Normalize shorthand relative durations to phrases Chronic can parse.
|
|
13
|
+
# Examples:
|
|
14
|
+
# - "30m ago" => "30 minutes ago"
|
|
15
|
+
# - "-30m" => "30 minutes ago"
|
|
16
|
+
# - "2h30m" => "2 hours 30 minutes ago" (when default_past)
|
|
17
|
+
# - "2h 30m ago" => "2 hours 30 minutes ago"
|
|
18
|
+
# - "2:30 ago" => "2 hours 30 minutes ago"
|
|
19
|
+
# - "-2:30" => "2 hours 30 minutes ago"
|
|
20
|
+
# Accepts d,h,m units; hours:minutes pattern; optional leading '-'; optional 'ago'.
|
|
21
|
+
# @param value [String] the duration string to normalize
|
|
22
|
+
# @param default_past [Boolean] whether to default to past tense
|
|
23
|
+
# @return [String] the normalized duration string
|
|
24
|
+
def normalize_relative_duration(value, default_past: false)
|
|
25
|
+
return value if value.nil?
|
|
26
|
+
|
|
27
|
+
s = value.to_s.strip
|
|
28
|
+
return s if s.empty?
|
|
29
|
+
|
|
30
|
+
has_ago = s =~ /\bago\b/i
|
|
31
|
+
negative = s.start_with?('-')
|
|
32
|
+
|
|
33
|
+
text = s.sub(/^[-+]/, '')
|
|
34
|
+
|
|
35
|
+
# hours:minutes pattern (e.g., 2:30, 02:30)
|
|
36
|
+
if (m = text.match(/^(\d{1,2}):(\d{1,2})(?:\s*ago)?$/i))
|
|
37
|
+
hours = m[1].to_i
|
|
38
|
+
minutes = m[2].to_i
|
|
39
|
+
parts = []
|
|
40
|
+
parts << "#{hours} hours" if hours.positive?
|
|
41
|
+
parts << "#{minutes} minutes" if minutes.positive?
|
|
42
|
+
return "#{parts.join(' ')} ago"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Compound d/h/m (order independent, allow spaces): e.g., 1d2h30m, 2h 30m, 30m
|
|
46
|
+
days = hours = minutes = 0
|
|
47
|
+
found = false
|
|
48
|
+
if (dm = text.match(/(?:(\d+)\s*d)/i))
|
|
49
|
+
days = dm[1].to_i
|
|
50
|
+
found = true
|
|
51
|
+
end
|
|
52
|
+
if (hm = text.match(/(?:(\d+)\s*h)/i))
|
|
53
|
+
hours = hm[1].to_i
|
|
54
|
+
found = true
|
|
55
|
+
end
|
|
56
|
+
if (mm = text.match(/(?:(\d+)\s*m)/i))
|
|
57
|
+
minutes = mm[1].to_i
|
|
58
|
+
found = true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
if found
|
|
62
|
+
parts = []
|
|
63
|
+
parts << "#{days} days" if days.positive?
|
|
64
|
+
parts << "#{hours} hours" if hours.positive?
|
|
65
|
+
parts << "#{minutes} minutes" if minutes.positive?
|
|
66
|
+
# Determine if we should make it past-tense
|
|
67
|
+
return "#{parts.join(' ')} ago" if negative || has_ago || default_past
|
|
68
|
+
|
|
69
|
+
return parts.join(' ')
|
|
70
|
+
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Fall through: not a shorthand we handle
|
|
74
|
+
s
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Parse a natural-language/iso date string for a start time
|
|
78
|
+
# @param value [String] the date string to parse
|
|
79
|
+
# @return [Time] the parsed date, or nil if parsing fails
|
|
80
|
+
def parse_date_begin(value)
|
|
81
|
+
return nil if value.nil? || value.to_s.strip.empty?
|
|
82
|
+
|
|
83
|
+
# Prefer explicit ISO first (only if the value looks ISO-like)
|
|
84
|
+
iso_rx = /\A\d{4}-\d{2}-\d{2}(?:[ T]\d{1,2}:\d{2}(?::\d{2})?)?\z/
|
|
85
|
+
if value.to_s.strip =~ iso_rx
|
|
86
|
+
begin
|
|
87
|
+
return Time.parse(value)
|
|
88
|
+
rescue StandardError
|
|
89
|
+
# fall through to chronify
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Fallback to chronify with guess begin
|
|
94
|
+
begin
|
|
95
|
+
# Normalize shorthand (e.g., 2h30m, -2:30, 30m ago)
|
|
96
|
+
txt = normalize_relative_duration(value.to_s, default_past: true)
|
|
97
|
+
# Bias to past for expressions like "ago", "yesterday", or "last ..."
|
|
98
|
+
future = txt !~ /(\bago\b|yesterday|\blast\b)/i
|
|
99
|
+
result = txt.chronify(guess: :begin, future: future)
|
|
100
|
+
NA.notify("Parsed '#{value}' as #{result}", debug: true) if result
|
|
101
|
+
result
|
|
102
|
+
rescue StandardError
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Parse a natural-language/iso date string for an end time
|
|
108
|
+
# @param value [String] the date string to parse
|
|
109
|
+
# @return [Time] the parsed date, or nil if parsing fails
|
|
110
|
+
def parse_date_end(value)
|
|
111
|
+
return nil if value.nil? || value.to_s.strip.empty?
|
|
112
|
+
|
|
113
|
+
# Prefer explicit ISO first (only if the value looks ISO-like)
|
|
114
|
+
iso_rx = /\A\d{4}-\d{2}-\d{2}(?:[ T]\d{1,2}:\d{2}(?::\d{2})?)?\z/
|
|
115
|
+
if value.to_s.strip =~ iso_rx
|
|
116
|
+
begin
|
|
117
|
+
return Time.parse(value)
|
|
118
|
+
rescue StandardError
|
|
119
|
+
# fall through to chronify
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Fallback to chronify with guess end
|
|
124
|
+
value.to_s.chronify(guess: :end, future: false)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Convert duration expressions to seconds
|
|
128
|
+
# Supports: "90" (minutes), "45m", "2h", "1d2h30m", with optional leading '-' or trailing 'ago'
|
|
129
|
+
# Also supports "2:30", "2:30 ago", and word forms like "2 hours 30 minutes (ago)"
|
|
130
|
+
# @param value [String] the duration string to parse
|
|
131
|
+
# @return [Integer] the duration in seconds, or nil if parsing fails
|
|
132
|
+
def parse_duration_seconds(value)
|
|
133
|
+
return nil if value.nil?
|
|
134
|
+
|
|
135
|
+
s = value.to_s.strip
|
|
136
|
+
return nil if s.empty?
|
|
137
|
+
|
|
138
|
+
# Strip leading sign and optional 'ago'
|
|
139
|
+
s = s.sub(/^[-+]/, '')
|
|
140
|
+
s = s.sub(/\bago\b/i, '').strip
|
|
141
|
+
|
|
142
|
+
# H:MM pattern
|
|
143
|
+
m = s.match(/^(\d{1,2}):(\d{1,2})$/)
|
|
144
|
+
if m
|
|
145
|
+
hours = m[1].to_i
|
|
146
|
+
minutes = m[2].to_i
|
|
147
|
+
return (hours * 3600) + (minutes * 60)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# d/h/m compact with letters, order independent (e.g., 1d2h30m, 2h 30m, 30m)
|
|
151
|
+
m = s.match(/^(?:(?<day>\d+)\s*d)?\s*(?:(?<hour>\d+)\s*h)?\s*(?:(?<min>\d+)\s*m)?$/i)
|
|
152
|
+
if m && !m[0].strip.empty? && (m['day'] || m['hour'] || m['min'])
|
|
153
|
+
return [[m['day'], 86_400], [m['hour'], 3600], [m['min'], 60]].map { |q, mult| q ? q.to_i * mult : 0 }.sum
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Word forms: e.g., "2 hours 30 minutes", "1 day 2 hours", etc.
|
|
157
|
+
days = 0
|
|
158
|
+
hours = 0
|
|
159
|
+
minutes = 0
|
|
160
|
+
found_word = false
|
|
161
|
+
if (dm = s.match(/(\d+)\s*(?:day|days)\b/i))
|
|
162
|
+
days = dm[1].to_i
|
|
163
|
+
found_word = true
|
|
164
|
+
end
|
|
165
|
+
if (hm = s.match(/(\d+)\s*(?:hour|hours|hr|hrs)\b/i))
|
|
166
|
+
hours = hm[1].to_i
|
|
167
|
+
found_word = true
|
|
168
|
+
end
|
|
169
|
+
if (mm = s.match(/(\d+)\s*(?:minute|minutes|min|mins)\b/i))
|
|
170
|
+
minutes = mm[1].to_i
|
|
171
|
+
found_word = true
|
|
172
|
+
end
|
|
173
|
+
return (days * 86_400) + (hours * 3600) + (minutes * 60) if found_word
|
|
174
|
+
|
|
175
|
+
# Plain number => minutes
|
|
176
|
+
return s.to_i * 60 if s =~ /^\d+$/
|
|
177
|
+
|
|
178
|
+
# Last resort: try chronify two points and take delta
|
|
179
|
+
begin
|
|
180
|
+
start = Time.now
|
|
181
|
+
finish = s.chronify(context: 'now', guess: :end, future: false)
|
|
182
|
+
return (finish - start).abs.to_i if finish
|
|
183
|
+
rescue StandardError
|
|
184
|
+
# ignore
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
nil
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
data/lib/na/version.rb
CHANGED
data/lib/na.rb
CHANGED
data/src/_README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
_If you're one of the rare people like me who find this useful, feel free to
|
|
10
10
|
[buy me some coffee][donate]._
|
|
11
11
|
|
|
12
|
-
The current version of `na` is <!--VER-->1.2.
|
|
12
|
+
The current version of `na` is <!--VER-->1.2.86<!--END VER-->.
|
|
13
13
|
|
|
14
14
|
`na` ("next action") is a command line tool designed to make it easy to see what your next actions are for any project, right from the command line. It works with TaskPaper-formatted files (but any plain text format will do), looking for `@na` tags (or whatever you specify) in todo files in your current folder.
|
|
15
15
|
|
|
@@ -235,6 +235,49 @@ See the help output for a list of all available actions.
|
|
|
235
235
|
@cli(bundle exec bin/na help update)
|
|
236
236
|
```
|
|
237
237
|
|
|
238
|
+
#### Time tracking
|
|
239
|
+
|
|
240
|
+
`na` supports tracking elapsed time between a start and finish for actions using `@started(YYYY-MM-DD HH:MM)` and `@done(YYYY-MM-DD HH:MM)` tags. Durations are not stored; they are calculated on the fly from these tags.
|
|
241
|
+
|
|
242
|
+
- Add/Finish/Update flags:
|
|
243
|
+
- `--started TIME` set a start time when creating or finishing an item
|
|
244
|
+
- `--end TIME` (alias `--finished`) set a done time
|
|
245
|
+
- `--duration DURATION` backfill start time from the provided end time
|
|
246
|
+
- All flags accept natural language (via Chronic) and shorthand: `30m ago`, `-2h`, `2h30m`, `2:30 ago`, `yesterday 5pm`
|
|
247
|
+
|
|
248
|
+
Examples:
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
na add --started "30 minutes ago" "Investigate bug"
|
|
252
|
+
na complete --finished now --duration 2h30m "Investigate bug"
|
|
253
|
+
na update --started "yesterday 3pm" --end "yesterday 5:15pm" "Investigate bug"
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
- Display flags (next/tagged):
|
|
257
|
+
- `--times` show per‑action durations and a grand total (implies `--done`)
|
|
258
|
+
- `--human` format durations as human‑readable text instead of `DD:HH:MM:SS`
|
|
259
|
+
- `--only_timed` show only actions that have both `@started` and `@done` (implies `--times --done`)
|
|
260
|
+
- `--only_times` output only the totals section (no action lines; implies `--times --done`)
|
|
261
|
+
- `--json_times` output a JSON object with timed items, per‑tag totals, and overall total (implies `--times --done`)
|
|
262
|
+
|
|
263
|
+
Example outputs:
|
|
264
|
+
|
|
265
|
+
```bash
|
|
266
|
+
# Per‑action durations appended and totals table
|
|
267
|
+
na next --times --human
|
|
268
|
+
|
|
269
|
+
# Only totals table (Markdown), no action lines
|
|
270
|
+
na tagged "tag*=bug" --only_times
|
|
271
|
+
|
|
272
|
+
# JSON for scripting
|
|
273
|
+
na next --json_times > times.json
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Notes:
|
|
277
|
+
|
|
278
|
+
- Any newly added or edited action text is scanned for natural‑language values in `@started(...)`/`@done(...)` and normalized to `YYYY‑MM‑DD HH:MM`.
|
|
279
|
+
- The color of durations in output is configurable via the theme key `duration` (defaults to `{y}`).
|
|
280
|
+
|
|
238
281
|
##### changelog
|
|
239
282
|
|
|
240
283
|
View recent changes with `na changelog` or `na changes`.
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: na
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.2.
|
|
4
|
+
version: 1.2.87
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Brett Terpstra
|
|
@@ -245,6 +245,7 @@ extra_rdoc_files:
|
|
|
245
245
|
- README.md
|
|
246
246
|
- na.rdoc
|
|
247
247
|
files:
|
|
248
|
+
- ".cursor/commands/changelog.md"
|
|
248
249
|
- ".cursor/commands/priority35m36m335m32m.md"
|
|
249
250
|
- ".rubocop.yml"
|
|
250
251
|
- ".rubocop_todo.yml"
|
|
@@ -303,6 +304,7 @@ files:
|
|
|
303
304
|
- lib/na/string.rb
|
|
304
305
|
- lib/na/theme.rb
|
|
305
306
|
- lib/na/todo.rb
|
|
307
|
+
- lib/na/types.rb
|
|
306
308
|
- lib/na/version.rb
|
|
307
309
|
- na.gemspec
|
|
308
310
|
- na.rdoc
|