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

Reply via email to