Commit a5e983770ec3

Vincent Demeester <vincent@sbr.pm>
2025-12-23 13:16:42
feat(org): Add comprehensive time tracking with clock operations
Implement P2-priority time tracking functionality for task time management: Core features: - clock-in: Start tracking time on a task (creates CLOCK entry in :LOGBOOK:) - clock-out: Stop tracking and calculate elapsed time - get-active-clock: Query currently clocked task - get-clocked-time: Get total time spent on a task (in minutes) Implementation details: - clock-in uses org-clock-in for native LOGBOOK integration - clock-out manually closes clock entries with calculated duration - Parses org-parse-time-string for accurate time calculations - Returns time in minutes via org-clock-sum CLI interface: - clock-in <file> <heading>: Start tracking - clock-out <file>: Stop current clock - get-active-clock <file>: Show active clock - get-clocked-time <file> <heading>: Total time report Test coverage: - 5 new tests covering all time tracking operations - Tests clock creation, closure, active detection, and time summation - All 32 tests passing Example workflow: # Start working org-manager clock-in todos.org "Implement feature X" # Check what's running org-manager get-active-clock todos.org # Stop working org-manager clock-out todos.org # Get total time spent org-manager get-clocked-time todos.org "Implement feature X" Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Signed-off-by: Vincent Demeester <vincent@sbr.pm>
1 parent 6cec87e
Changed files (4)
dots/.config/claude/skills/Org/tools/tests/batch-functions-test.el
@@ -386,5 +386,58 @@ Priority mapping: '1'=1, '2'=2, '3'=3, '4'=4, '5'=5."
            (should (= (alist-get 'priority task2) 1))
            (should-not (alist-get 'priority task3))))))))
 
+;;; Tests for Time Tracking
+
+(ert-deftest test-org-batch-clock-in ()
+  "Test clocking in to a task."
+  (batch-test--with-temp-org-file
+   "* Work\n** TODO Test Task\n"
+   (lambda (temp-file)
+     (let ((result (org-batch-clock-in temp-file "Test Task")))
+       (should result)
+       ;; Verify clock entry was added
+       (with-temp-buffer
+         (insert-file-contents temp-file)
+         (should (string-match-p "CLOCK: \\[" (buffer-string))))))))
+
+(ert-deftest test-org-batch-clock-out ()
+  "Test clocking out of a task."
+  (batch-test--with-temp-org-file
+   "* Work\n** TODO Test Task\n:LOGBOOK:\nCLOCK: [2025-12-23 Mon 10:00]\n:END:\n"
+   (lambda (temp-file)
+     (let ((result (org-batch-clock-out temp-file)))
+       (should result)
+       ;; Verify clock was closed with end time
+       (with-temp-buffer
+         (insert-file-contents temp-file)
+         (should (string-match-p "CLOCK: \\[.*?\\]--\\[.*?\\] =>" (buffer-string))))))))
+
+(ert-deftest test-org-batch-get-active-clock ()
+  "Test getting active clock."
+  (batch-test--with-temp-org-file
+   "* Work\n** TODO Test Task\n:LOGBOOK:\nCLOCK: [2025-12-23 Mon 10:00]\n:END:\n"
+   (lambda (temp-file)
+     (let ((result (org-batch-get-active-clock temp-file)))
+       (should result)
+       (should (string= (alist-get 'heading result) "Test Task"))
+       (should (alist-get 'clock_start result))))))
+
+(ert-deftest test-org-batch-get-active-clock-none ()
+  "Test getting active clock when none exists."
+  (batch-test--with-temp-org-file
+   "* Work\n** TODO Test Task\n"
+   (lambda (temp-file)
+     (let ((result (org-batch-get-active-clock temp-file)))
+       (should-not result)))))
+
+(ert-deftest test-org-batch-get-clocked-time ()
+  "Test getting total clocked time for a task."
+  (batch-test--with-temp-org-file
+   "* Work\n** TODO Test Task\n:LOGBOOK:\nCLOCK: [2025-12-23 Mon 10:00]--[2025-12-23 Mon 11:30] =>  1:30\n:END:\n"
+   (lambda (temp-file)
+     (let ((minutes (org-batch-get-clocked-time temp-file "Test Task")))
+       (should (> minutes 0))
+       (should (= minutes 90))))))  ; 1:30 = 90 minutes
+
 (provide 'batch-functions-test)
 ;;; batch-functions-test.el ends here
dots/.config/claude/skills/Org/tools/batch-functions.el
@@ -694,6 +694,86 @@ Returns count of updated tasks."
       (write-region (point-min) (point-max) file))
     count))
 
+;;; Time Tracking
+
+(defun org-batch-clock-in (file heading)
+  "Clock in to task with HEADING in FILE.
+Returns t on success, nil if heading not found."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (goto-char (point-min))
+    (let ((found nil)
+          (heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\|DONE\\|CANX\\)? ?\\(?:\\[#[1-5]\\] \\)?"
+                                  (regexp-quote heading))))
+      (when (re-search-forward heading-regexp nil t)
+        (org-back-to-heading)
+        (org-clock-in)
+        (write-region (point-min) (point-max) file)
+        (setq found t))
+      found)))
+
+(defun org-batch-clock-out (file)
+  "Clock out of currently clocked task in FILE.
+Returns t on success, nil if no active clock found."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (goto-char (point-min))
+    (let ((found nil))
+      ;; Find active clock line (has start time but no end time)
+      (when (re-search-forward "^\\([ \t]*CLOCK: \\)\\(\\[.*?\\]\\)$" nil t)
+        (let ((indent (match-string 1))
+              (start-time (match-string 2))
+              (end-time (format-time-string "[%Y-%m-%d %a %H:%M]")))
+          ;; Calculate duration
+          (let* ((start-ts (org-parse-time-string start-time))
+                 (start-encoded (apply #'encode-time start-ts))
+                 (end-encoded (current-time))
+                 (duration-seconds (float-time (time-subtract end-encoded start-encoded)))
+                 (hours (floor (/ duration-seconds 3600)))
+                 (minutes (floor (/ (mod duration-seconds 3600) 60))))
+            ;; Replace the line with closed clock entry
+            (replace-match (format "%s%s--%s => %2d:%02d" indent start-time end-time hours minutes))
+            (write-region (point-min) (point-max) file)
+            (setq found t))))
+      found)))
+
+(defun org-batch-get-active-clock (file)
+  "Get currently active clock in FILE.
+Returns alist with heading and clock-in time, or nil if no active clock."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (goto-char (point-min))
+    (let ((result nil))
+      ;; Find active clock line (no end time)
+      (when (re-search-forward "^[ \t]*CLOCK: \\(\\[.*?\\]\\)$" nil t)
+        (let ((clock-start (match-string 1)))
+          (org-back-to-heading)
+          (let ((heading (org-element-property :raw-value (org-element-at-point))))
+            (setq result `((heading . ,heading)
+                          (clock_start . ,clock-start))))))
+      result)))
+
+(defun org-batch-get-clocked-time (file heading)
+  "Get total clocked time for HEADING in FILE.
+Returns minutes as integer."
+  (with-temp-buffer
+    (insert-file-contents file)
+    (org-mode)
+    (goto-char (point-min))
+    (let ((heading-regexp (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\|DONE\\|CANX\\)? ?\\(?:\\[#[1-5]\\] \\)?"
+                                  (regexp-quote heading)))
+          (total-minutes 0))
+      (when (re-search-forward heading-regexp nil t)
+        (org-back-to-heading)
+        (save-restriction
+          (org-narrow-to-subtree)
+          (org-clock-sum)
+          (setq total-minutes (get-text-property (point) :org-clock-minutes))))
+      (or total-minutes 0))))
+
 ;;; Output Functions
 
 (defun org-batch-output-json (success data &optional error)
dots/.config/claude/skills/Org/tools/org-manager
@@ -187,6 +187,23 @@ BULK OPERATIONS:
       Priority: 1-5 (1=highest, 5=lowest)
       Example: org-manager bulk-set-priority todos.org TODO 1
 
+TIME TRACKING:
+  clock-in <file> <heading>
+      Start time tracking on a task
+      Creates CLOCK entry in :LOGBOOK: drawer
+
+  clock-out <file>
+      Stop time tracking on currently clocked task
+      Records total time spent
+
+  get-active-clock <file>
+      Get currently clocked task (if any)
+      Returns heading and clock-in time
+
+  get-clocked-time <file> <heading>
+      Get total time spent on a task (in minutes)
+      Sums all CLOCK entries for the task
+
 DENOTE COMMANDS:
   denote-create <title> <tags> [--signature=SIG] [--category=CAT] [--directory=DIR] [--content=FILE]
       Create a denote-formatted note with proper naming and frontmatter
@@ -811,6 +828,70 @@ cmd_bulk_set_priority() {
     run_elisp "$elisp"
 }
 
+# Time tracking commands
+
+cmd_clock_in() {
+    local file="$1"
+    local heading="$2"
+
+    [[ -f "$file" ]] || error "File not found: $file"
+    [[ -n "$heading" ]] || error "Heading required"
+
+    local elisp="(progn
+      (let ((result (org-batch-clock-in \"$file\" \"$heading\")))
+        (if result
+            (org-batch-output-json t (list :clocked_in t :heading \"$heading\"))
+          (org-batch-output-error \"Heading not found: $heading\")))
+      (kill-emacs 0))"
+
+    run_elisp "$elisp"
+}
+
+cmd_clock_out() {
+    local file="$1"
+
+    [[ -f "$file" ]] || error "File not found: $file"
+
+    local elisp="(progn
+      (let ((result (org-batch-clock-out \"$file\")))
+        (if result
+            (org-batch-output-json t (list :clocked_out t))
+          (org-batch-output-error \"No active clock found\")))
+      (kill-emacs 0))"
+
+    run_elisp "$elisp"
+}
+
+cmd_get_active_clock() {
+    local file="$1"
+
+    [[ -f "$file" ]] || error "File not found: $file"
+
+    local elisp="(progn
+      (let ((result (org-batch-get-active-clock \"$file\")))
+        (if result
+            (org-batch-output-json t result)
+          (org-batch-output-json t nil)))
+      (kill-emacs 0))"
+
+    run_elisp "$elisp"
+}
+
+cmd_get_clocked_time() {
+    local file="$1"
+    local heading="$2"
+
+    [[ -f "$file" ]] || error "File not found: $file"
+    [[ -n "$heading" ]] || error "Heading required"
+
+    local elisp="(progn
+      (let ((minutes (org-batch-get-clocked-time \"$file\" \"$heading\")))
+        (org-batch-output-json t (list :heading \"$heading\" :minutes minutes)))
+      (kill-emacs 0))"
+
+    run_elisp "$elisp"
+}
+
 # Denote commands
 
 cmd_denote_create() {
@@ -1045,6 +1126,18 @@ main() {
         bulk-set-priority)
             cmd_bulk_set_priority "$@"
             ;;
+        clock-in)
+            cmd_clock_in "$@"
+            ;;
+        clock-out)
+            cmd_clock_out "$@"
+            ;;
+        get-active-clock)
+            cmd_get_active_clock "$@"
+            ;;
+        get-clocked-time)
+            cmd_get_clocked_time "$@"
+            ;;
         denote-create)
             cmd_denote_create "$@"
             ;;
dots/.config/claude/skills/Org/SKILL.md
@@ -97,6 +97,21 @@ Provide reliable, programmatic access to org-mode files using Emacs batch mode a
 ./tools/org-manager bulk-set-priority ~/desktop/org/todos.org "TODO" 1
 ```
 
+#### Time Tracking
+```bash
+# Start time tracking on a task
+./tools/org-manager clock-in ~/desktop/org/todos.org "Implement feature X"
+
+# Stop time tracking (clocks out of currently active task)
+./tools/org-manager clock-out ~/desktop/org/todos.org
+
+# Check what task is currently being tracked
+./tools/org-manager get-active-clock ~/desktop/org/todos.org
+
+# Get total time spent on a task (returns minutes)
+./tools/org-manager get-clocked-time ~/desktop/org/todos.org "Implement feature X"
+```
+
 #### Denote Operations
 ```bash
 # Create denote-formatted note