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.
@@ -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
- add.process(priority: priority, finish: finish, add_tag: add_tag, remove_tag: remove_tag)
363
-
364
- # Remove the original action and its notes
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
- # Insert at correct location: if moving, insert at start/end of target project
388
- if move && target_proj
389
- insert_line = if append
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
- # Not moving, update in-place
399
- contents.insert(action_line, "#{indent}\t- #{add.action}#{note_str}")
400
- end
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
- notify(add.pretty)
442
+ notify(add.pretty)
443
+ end
403
444
 
404
445
  # Track affected action and description
405
- changes = ['updated']
406
- changes << 'finished' if finish
407
- changes << "priority=#{priority}" if priority.to_i.positive?
408
- changes << "tags+#{add_tag.join(',')}" unless add_tag.nil? || add_tag.empty?
409
- changes << "tags-#{remove_tag.join(',')}" unless remove_tag.nil? || remove_tag.empty?
410
- changes << 'note updated' unless note.nil? || note.empty?
411
- changes << "moved to #{target_proj.project}" if move && target_proj
412
- affected_actions << { action: add, desc: changes.join(', ') }
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, finish: finish, add_tag: add_tag, remove_tag: remove_tag)
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, add: action, project: project, add_tag: add_tag, priority: priority, finish: finish,
549
- append: append)
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 "\n#{self}" if width <= 80
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
- future = t =~ /^(done|complete)/ ? false : true
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
@@ -57,6 +57,7 @@ module NA
57
57
  tags: '{m}',
58
58
  value_parens: '{m}',
59
59
  values: '{c}',
60
+ duration: '{y}',
60
61
  search_highlight: '{y}',
61
62
  note: '{dw}',
62
63
  dirname: '{xdw}',
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
@@ -5,5 +5,5 @@
5
5
  module Na
6
6
  ##
7
7
  # Current version of the na gem.
8
- VERSION = '1.2.86'
8
+ VERSION = '1.2.87'
9
9
  end
data/lib/na.rb CHANGED
@@ -31,6 +31,7 @@ require 'na/todo'
31
31
  require 'na/actions'
32
32
  require 'na/project'
33
33
  require 'na/action'
34
+ require 'na/types'
34
35
  require 'na/editor'
35
36
  require 'na/next_action'
36
37
  require 'na/prompt'
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.85<!--END VER-->.
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.86
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