You wouldn't believe how much time I spent on this. I tried rewriting `org-timestamp-change' from scratch. I would not recommend.
In an effort to clean my slate of half-finished projects I am offering you a "minimum viable solution". There is probably a nicer way of doing this but I don't want to look for it. With this patch applied, the code is no worse then before I messed it up. I have also summarized all the deficiencies we have identified with 'org-timestamp-change' in a new test.
>From 1190dcf17850b159b85710c881f1dd131e2c1693 Mon Sep 17 00:00:00 2001 From: Morgan Smith <[email protected]> Date: Mon, 8 Jun 2026 19:02:47 -0400 Subject: [PATCH 1/2] org.el: Fix 'org-timestamp-change' for timeranges Ever since commit d6baddca2, trying to use 'org-timestamp-change' on the end time of a time range would yield the error "cl-ecase failed". This commit removes that restriction. * lisp/org.el (org-timestamp-change): Allow the time addition to be bypassed if 'timestamp?' doesn't have an expected value. * testing/lisp/test-org.el (test-org/org-timestamp-change): Test on a timerange as well --- lisp/org.el | 21 +++++++++++---------- testing/lisp/test-org.el | 24 +++++++++++++++--------- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/lisp/org.el b/lisp/org.el index b418bd7ec..04eed3088 100644 --- a/lisp/org.el +++ b/lisp/org.el @@ -15712,16 +15712,17 @@ org-timestamp-change (setq dm 1)) (setq time (org-encode-time - (org-decoded-time-add - time0 - (make-decoded-time - (cl-ecase timestamp? - (minute :minute) - (hour :hour) - (day :day) - (month :month) - (year :year)) - increment))))) + (if-let* ((unit + (cl-case timestamp? + (minute :minute) + (hour :hour) + (day :day) + (month :month) + (year :year)))) + (org-decoded-time-add + time0 + (make-decoded-time unit increment)) + time0)))) ;; Validation if we're modifying hour or minute fields (when (and with-hm (memq timestamp? '(hour minute)) diff --git a/testing/lisp/test-org.el b/testing/lisp/test-org.el index 80f95f3dd..81747faec 100644 --- a/testing/lisp/test-org.el +++ b/testing/lisp/test-org.el @@ -9523,12 +9523,15 @@ test-org/at-timestamp-p (ert-deftest test-org/org-timestamp-change () "Test `org-timestamp-change' specifications." - (let ((now (decode-time)) now-ts point) + (let ((now (decode-time)) point) ;; Decrementing a month from March 31st yields February ;; 28th. This particular test is easier to write if the ;; days don't change when modifying the month (setf (decoded-time-day now) (min (decoded-time-day now) 28)) + ;; So that our timerange doesn't overflow + (setf (decoded-time-hour now) + (min (decoded-time-day now) 22)) (setq now (encode-time now)) (message "Testing with timestamps <%s> and <%s>" (format-time-string (car org-timestamp-formats) now) @@ -9540,13 +9543,16 @@ test-org/org-timestamp-change (cons (replace-regexp-in-string " %a" "" (car org-timestamp-formats)) (replace-regexp-in-string - " %a" "" (cdr org-timestamp-formats))))) - ;; loop over timestamps that do not and do contain time - (dolist (format (list (car org-timestamp-formats) - (cdr org-timestamp-formats))) - (setq now-ts - (concat "<" (format-time-string format now) ">")) - (org-test-with-temp-text now-ts + " %a" "" (cdr org-timestamp-formats))))) + (dolist + (ts (list + ;; Date + (concat "<" (format-time-string (car org-timestamp-formats) now) ">") + ;; Date + Time + (concat "<" (format-time-string (cdr org-timestamp-formats) now) ">") + ;; Time range + (concat "<" (format-time-string (cdr org-timestamp-formats) now) "-23:00>"))) + (org-test-with-temp-text ts (forward-char 1) (while (not (eq (char-after) ?>)) (skip-syntax-forward "-") @@ -9563,7 +9569,7 @@ test-org/org-timestamp-change (goto-char point) (should (string= (buffer-substring (point-min) (point-max)) - now-ts)) + ts)) (forward-char 1)))))) ;; Corner cases (let ((org-timestamp-formats base-commit: 4dc39c7eb3d481984fabfe2bfe578da51fb9c779 -- 2.54.0
>From 22e1e4bfa45d3785940060a6cb6f6165f22058a7 Mon Sep 17 00:00:00 2001 From: Morgan Smith <[email protected]> Date: Mon, 8 Jun 2026 19:49:40 -0400 Subject: [PATCH 2/2] Testing: New test 'test-org/org-timestamp-change/bad' This test documents the many broken promises made by 'org-timestamp-change'. * testing/lisp/test-org.el (test-org/org-timestamp-change/bad): New test. --- testing/lisp/test-org.el | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/testing/lisp/test-org.el b/testing/lisp/test-org.el index 81747faec..aabf18bfa 100644 --- a/testing/lisp/test-org.el +++ b/testing/lisp/test-org.el @@ -9603,6 +9603,36 @@ test-org/org-timestamp-change (test-time-stamp-rounding "<2026-03-14 12:00>" (1+ i) -1 (concat "11:5" (number-to-string (- 9 i))))))) +(ert-deftest test-org/org-timestamp-change/bad () + "Promises that are currently broken in `org-timestamp-change'." + :expected-result :failed + (cl-flet ((test-change (text &rest args) + (org-test-with-temp-text text + (apply #'org-timestamp-change args) + (buffer-string)))) + (should + (string-equal + (test-change "<2026-04-18 Sat 21:00-23:00>" 1 'hour) + ;; Actually: <2026-04-18 Sat 22:00-00:00> + "<2026-04-18 Sat 22:00-24:00>")) + (should + (string-equal + (test-change "<2026-04-18 Sat 21:00-23:00>" 2 'hour) + ;; Actually: <2026-04-18 Sat 23:00-01:00> + "<2026-04-18 Sat 23:00-25:00>")) + ;; Duration should remain constant + (should + (string-equal + (test-change "<2026-04-18 Sat 15:10-15:11>" 1 'minute 'updown) + ;; Actually: <2026-04-18 Sat 15:15-15:15> + "<2026-04-18 Sat 15:15-15:16>")) + ;; Should respect `org-timestamp-rounding-minutes' + (should + (string-equal + (test-change "<2026-04-18 Sat 15:10-15:<point>11>" 1 nil 'updown) + ;; Actually: <2026-04-18 Sat 15:10-15:12> + "<2026-04-18 Sat 15:14-15:15>")))) + (ert-deftest test-org/org-timestamp-change-dst () "Test that `org-timestamp-change' properly errors at DST boundaries." (org-test-with-timezone "America/New_York" -- 2.54.0
