Hi Ihor!

My apologies for not acknowledging your feedback sooner. I started working on 
it right away, and then "life" happened.

I've been lightly test-driving the change for the past week and it works for 
me, though I don't normally use repeaters and S-expressions much, if at all.

I rebased all my changes into two self-contained commits to make the review 
less painful.

Best regards,
Dmytro
—
em-dashes are my own

On Tue, Aug 25, 2026, at 07:05, Ihor Radchenko wrote:
> Ihor Radchenko <[email protected]> writes:
>
>> "Dmytro Kostiuchenko" <[email protected]> writes:
>> ...
>> That said, I agree that it does not have to be something that must be
>> implemented. We may start from easier format and add offset+timezone
>> name in the future.
>
> It has been a while. Dmytro, did you have a chance to look into my
> latest response?
>
> -- 
> Ihor Radchenko // yantar92,
> Org mode maintainer,
> Learn more about Org mode at <https://orgmode.org/>.
> Support Org development at <https://liberapay.com/org-mode>,
> or support my work at <https://liberapay.com/yantar92>
From 78d6fae5d979cf4a839f9492d149a124f13047b5 Mon Sep 17 00:00:00 2001
From: Dmytro Kostiuchenko <[email protected]>
Date: Tue, 25 Aug 2026 08:25:17 +0200
Subject: [PATCH 1/2] org: Support IANA time zones in timestamps

A timestamp may carry an explicit time zone written as "@TZSPEC" after
the time, e.g. <2026-07-19 Sun 12:00 @Europe/Kyiv>, where TZSPEC is an
IANA zone name or a numeric offset.  A timestamp without a zone keeps its
previous meaning of local time.

* lisp/org-macs.el (org-ts-regexp-zone): New regexp matching a "@TZSPEC".
(org-timestamp--encode-zone): New; turn a TZSPEC into a value usable as
an `encode-time' zone.
(org-parse-time-string): Store the zone in the decoded-time zone slot, so
`org-time-string-to-time', `org-2ft' and the other string consumers
resolve it.
* lisp/org-element.el (org-element-timestamp-parser)
(org-element-timestamp-interpreter): Parse and write back the zone as the
:zone-start and :zone-end properties.
(org-element--timestamp-zone-regexp): Alias of `org-ts-regexp-zone'.
* lisp/org.el (org-ts-regexp-trailer-length, org-ts-regexp2)
(org-ts-regexp3): Leave room for a zone before the closing bracket.
(org-timestamp-to-time, org-timestamp-from-time): Handle the zone.
(org-timestamp-to-local): New; return a timestamp converted to a zone
(local by default).
* testing/org-test.el (org-test-with-timezone): Move here from
test-org.el so any test file can bind TZ.
* testing/lisp/test-org.el, testing/lisp/test-org-element.el: Add tests.
---
 lisp/org-element.el              |  39 +++++++-
 lisp/org-macs.el                 |  58 +++++++++---
 lisp/org.el                      |  99 +++++++++++++++++---
 testing/lisp/test-org-element.el |  70 ++++++++++++++
 testing/lisp/test-org.el         | 155 ++++++++++++++++++++++++++++---
 testing/org-test.el              |  11 +++
 6 files changed, 389 insertions(+), 43 deletions(-)

diff --git a/lisp/org-element.el b/lisp/org-element.el
index 58c65ad08..87f104eb9 100644
--- a/lisp/org-element.el
+++ b/lisp/org-element.el
@@ -4408,6 +4408,10 @@ Assume point is at the target."
           "\\)\\)?")
   "Regexp for matching raw value of a timestamp.")
 
+(defvaralias 'org-element--timestamp-zone-regexp 'org-ts-regexp-zone
+  "Regexp matching a TZSPEC in a timestamp.
+Alias for `org-ts-regexp-zone', kept as the old name used in `org-element'.")
+
 (defun org-element-timestamp-parser ()
   "Parse time stamp at point, if any.
 
@@ -4417,8 +4421,13 @@ containing `:type', `:range-type', `:raw-value', `:year-start',
 `:year-end', `:month-end', `:day-end', `:hour-end', `:minute-end',
 `:repeater-type', `:repeater-value', `:repeater-unit',
 `:repeater-deadline-value', `:repeater-deadline-unit', `:warning-type',
-`:warning-value', `:warning-unit', `:diary-sexp', `:begin', `:end' and
-`:post-blank' properties.  Otherwise, return nil.
+`:warning-value', `:warning-unit', `:zone-start', `:zone-end',
+`:diary-sexp', `:begin', `:end' and `:post-blank' properties.
+Otherwise, return nil.
+
+`:zone-start' and `:zone-end' hold the TZSPEC (an IANA name or a numeric
+offset) explicitly written after the start and end date/time, or nil when
+none is present.
 
 Assume point is at the beginning of the timestamp."
   (when (looking-at-p org-element--timestamp-regexp)
@@ -4502,6 +4511,13 @@ Assume point is at the beginning of the timestamp."
 		    :warning-unit
 		    (pcase (string-to-char (match-string 3 raw-value))
 		      (?h 'hour) (?d 'day) (?w 'week) (?m 'month) (_ 'year)))))
+	     (zone-start
+	      (and (string-match org-element--timestamp-zone-regexp date-start)
+		   (match-string 1 date-start)))
+	     (zone-end
+	      (and date-end
+		   (string-match org-element--timestamp-zone-regexp date-end)
+		   (match-string 1 date-end)))
 	     year-start month-start day-start hour-start minute-start year-end
 	     month-end day-end hour-end minute-end)
 	;; Parse date-start.
@@ -4548,6 +4564,8 @@ Assume point is at the beginning of the timestamp."
 		      :day-end day-end
 		      :hour-end hour-end
 		      :minute-end minute-end
+		      :zone-start zone-start
+		      :zone-end zone-end
 		      :begin begin
 		      :end end
 		      :post-blank post-blank)
@@ -4593,6 +4611,12 @@ Assume point is at the beginning of the timestamp."
 	           (`hour "h") (`day "d") (`week "w") (`month "m") (`year "y"))))
                (hour-start (org-element-property :hour-start timestamp))
                (minute-start (org-element-property :minute-start timestamp))
+               (zone-start-string
+                (let ((z (org-element-property :zone-start timestamp)))
+                  (and (org-string-nw-p z) (concat " @" z))))
+               (zone-end-string
+                (let ((z (org-element-property :zone-end timestamp)))
+                  (and (org-string-nw-p z) (concat " @" z))))
                (brackets
                 (if (member
                      type
@@ -4625,6 +4649,10 @@ Assume point is at the beginning of the timestamp."
 	      (org-encode-time
 	       0 (or minute-start 0) (or hour-start 0)
 	       day-start month-start year-start)))
+           ;; Zone for the start date/time.  For time ranges the zone
+           ;; is appended after the end time instead (see below), so
+           ;; the HH:MM-HH:MM time range is not split.
+           (unless (eq range-type 'timerange) zone-start-string)
            ;; Range: -HH:MM or TIMESTAMP-END--[YYYY-MM-DD DAY HH:MM]
            (let ((hour-end (org-element-property :hour-end timestamp))
                  (minute-end (org-element-property :minute-end timestamp)))
@@ -4656,7 +4684,8 @@ Assume point is at the beginning of the timestamp."
                   ;; End time: -HH:MM.
                   ;; Fall back to start time if end time is not defined (arbitrary historical choice).
                   ;; Error will be thrown if both end and begin time is not defined.
-                  (`timerange (format "-%02d:%02d" (or hour-end hour-start) (or minute-end minute-start)))
+                  (`timerange (concat (format "-%02d:%02d" (or hour-end hour-start) (or minute-end minute-start))
+                                      zone-start-string))
                   ;; End date: TIMESTAMP-END--[YYYY-MM-DD DAY HH:MM
                   ((or `daterange
                        ;; Should never happen in the output of `org-element-timestamp-parser'.
@@ -4685,7 +4714,9 @@ Assume point is at the beginning of the timestamp."
                       ;; date.
 	              (or (org-element-property :day-end timestamp) day-start)
 	              (or (org-element-property :month-end timestamp) month-start)
-	              (or (org-element-property :year-end timestamp) year-start)))))))))
+	              (or (org-element-property :year-end timestamp) year-start)))
+                    ;; Zone for the end date/time of a date range.
+                    zone-end-string))))))
            ;; repeater + warning + closing > or ]
            ;; This info is duplicated in date ranges.
            timestamp-end))))))
diff --git a/lisp/org-macs.el b/lisp/org-macs.el
index e267debba..ad3af0941 100644
--- a/lisp/org-macs.el
+++ b/lisp/org-macs.el
@@ -1649,12 +1649,41 @@ preferably the latest version."
 
 (declare-function make-decoded-time "time-date" (&rest args))
 
+(defconst org-ts-regexp-zone
+  "@\\([-+:/_[:alnum:]]+\\)"
+  "Regexp matching a TZSPEC in a timestamp.
+Group 1 is the TZSPEC: an IANA name (e.g. \"Europe/Kyiv\", \"UTC\") or a
+numeric offset (e.g. \"+02\", \"+0230\", \"-05:00\").  The leading \"@\"
+tells it apart from repeaters, warnings and the day name.")
+
+(defun org-timestamp--encode-zone (zone)
+  "Convert TZSPEC string ZONE to a value usable as `encode-time' ZONE.
+A nil ZONE returns nil, meaning local time.  A numeric offset
+\(e.g. \"+02\", \"+0230\", \"-05:00\") is converted to an integer number
+of seconds east of UTC.  Any other string (an IANA name such as
+\"Europe/Kyiv\" or \"UTC\") is returned unchanged, for the system time
+zone database to interpret."
+  (cond
+   ((null zone) nil)
+   ((string-match "\\`\\([-+]\\)\\([0-9]\\{1,2\\}\\):?\\([0-9]\\{2\\}\\)?\\'" zone)
+    (* (if (equal (match-string 1 zone) "-") -1 1)
+       (+ (* 3600 (string-to-number (match-string 2 zone)))
+          (* 60 (if (match-string 3 zone)
+                    (string-to-number (match-string 3 zone))
+                  0)))))
+   (t zone)))
+
 (defun org-parse-time-string (s &optional nodefault)
   "Parse Org time string S.
 
 If time is not given, defaults to 0:00.  However, with optional
 NODEFAULT, hour and minute fields are nil if not given.
 
+When S carries an explicit \"@TZSPEC\" zone, the returned decoded time
+has its zone slot set to a value usable by `encode-time' (see
+`org-timestamp--encode-zone').  Otherwise the zone slot is nil, so the
+time is read in the local time zone, as before.
+
 Throw an error if S does not contain a valid Org time string.
 Note that the first match for YYYY-MM-DD will be used (e.g.,
 \"-52000-02-03\" will be taken as \"2000-02-03\").
@@ -1662,19 +1691,26 @@ Note that the first match for YYYY-MM-DD will be used (e.g.,
 This should be a lot faster than the `parse-time-string'."
   (unless (string-match org-ts-regexp0 s)
     (error "Not an Org time string: %s" s))
-  (make-decoded-time
-   :second 0
-   :minute (cond ((match-beginning 8) (string-to-number (match-string 8 s)))
+  ;; Extract the zone with its own match, protecting the match data of
+  ;; `org-ts-regexp0' read by `make-decoded-time' below.
+  (let ((zone (save-match-data
+                (org-timestamp--encode-zone
+                 (and (string-match org-ts-regexp-zone s)
+                      (match-string 1 s))))))
+    (make-decoded-time
+     :second 0
+     :minute (cond ((match-beginning 8) (string-to-number (match-string 8 s)))
+                   (nodefault nil)
+                   (t 0))
+     :hour (cond ((match-beginning 7) (string-to-number (match-string 7 s)))
                  (nodefault nil)
                  (t 0))
-   :hour (cond ((match-beginning 7) (string-to-number (match-string 7 s)))
-               (nodefault nil)
-               (t 0))
-   :day (string-to-number (match-string 4 s))
-   :month (string-to-number (match-string 3 s))
-   :year (string-to-number (match-string 2 s))
-   ;; FIXME: -1 becomes the default from Emacs 29, but not in Emacs 28.
-   :dst -1))
+     :day (string-to-number (match-string 4 s))
+     :month (string-to-number (match-string 3 s))
+     :year (string-to-number (match-string 2 s))
+     ;; FIXME: -1 becomes the default from Emacs 29, but not in Emacs 28.
+     :dst -1
+     :zone zone)))
 
 (defun org-matcher-time (s)
   "Interpret a time comparison value S as a floating point time.
diff --git a/lisp/org.el b/lisp/org.el
index faecd2b93..2b62e2db7 100644
--- a/lisp/org.el
+++ b/lisp/org.el
@@ -468,10 +468,22 @@ This regular expression provides the following groups:
     7: hour
     8: minute")
 
-(defconst org-ts-regexp2 (concat "<" org-ts-regexp1 "[^>\n]\\{0,16\\}>")
+(defconst org-ts-regexp-trailer-length 48
+  "Maximum length of the trailing run of a timestamp before its closing bracket.
+This bounds the characters `org-ts-regexp2' and `org-ts-regexp3' allow
+between the time and the closing \">\"/\"]\", namely the repeater,
+the warning period, and the optional \"@TZSPEC\".  Must fit the longest
+realistic IANA zone name (e.g. \"America/Argentina/Buenos_Aires\", 28
+characters) and the rest.")
+
+(defconst org-ts-regexp2
+  (concat "<" org-ts-regexp1
+	  (format "[^>\n]\\{0,%d\\}>" org-ts-regexp-trailer-length))
   "Regular expression matching time stamps, with groups.")
 
-(defconst org-ts-regexp3 (concat "[[<]" org-ts-regexp1 "[^]>\n]\\{0,16\\}[]>]")
+(defconst org-ts-regexp3
+  (concat "[[<]" org-ts-regexp1
+	  (format "[^]>\n]\\{0,%d\\}[]>]" org-ts-regexp-trailer-length))
   "Regular expression matching time stamps (also [..]), with groups.")
 
 (defconst org-tr-regexp (concat org-ts-regexp "--?-?" org-ts-regexp)
@@ -20758,7 +20770,7 @@ Return nil if S is not a valid timestamp string."
       (save-excursion (insert s))
       (org-element-timestamp-parser))))
 
-(defun org-timestamp-from-time (time &optional with-time inactive)
+(defun org-timestamp-from-time (time &optional with-time inactive zone)
   "Convert a time value into a timestamp object.
 
 TIME is an Emacs internal time representation, as returned, e.g.,
@@ -20768,7 +20780,10 @@ When optional argument WITH-TIME is non-nil, return a timestamp
 object with a time part, i.e., with hours and minutes.
 
 Return an inactive timestamp if INACTIVE is non-nil.  Otherwise,
-return an active timestamp."
+return an active timestamp.
+
+When optional argument ZONE is non-nil, it is a TZSPEC (an IANA name or a
+numeric offset) stored as the `:zone-start' property."
   (pcase-let ((`(,_ ,minute ,hour ,day ,month ,year . ,_) (decode-time time)))
     (org-element-create 'timestamp
 			(list :type (if inactive 'inactive 'active)
@@ -20776,20 +20791,76 @@ return an active timestamp."
 			      :month-start month
 			      :day-start day
 			      :hour-start (and with-time hour)
-			      :minute-start (and with-time minute)))))
+			      :minute-start (and with-time minute)
+			      :zone-start zone))))
 
 (defun org-timestamp-to-time (timestamp &optional end)
   "Convert TIMESTAMP object into an Emacs internal time value.
 Use end of date range or time range when END is non-nil.
-Otherwise, use its start."
-  (org-encode-time
-   (append '(0)
-           (mapcar
-            (lambda (prop) (or (org-element-property prop timestamp) 0))
-            (if end '(:minute-end :hour-end :day-end :month-end :year-end)
-              '(:minute-start :hour-start :day-start :month-start
-                              :year-start)))
-           '(nil -1 nil))))
+Otherwise, use its start.
+
+When TIMESTAMP carries an explicit zone (its `:zone-start' or, for END,
+`:zone-end' property), the result is computed in that zone.  A time range
+without an explicit end zone uses the start zone.  A timestamp with no
+zone is read in the local time zone, as before."
+  (let ((zone (org-timestamp--encode-zone
+               (or (org-element-property (if end :zone-end :zone-start) timestamp)
+                   (and end (org-element-property :zone-start timestamp))))))
+    (org-encode-time
+     (append '(0)
+             (mapcar
+              (lambda (prop) (or (org-element-property prop timestamp) 0))
+              (if end '(:minute-end :hour-end :day-end :month-end :year-end)
+                '(:minute-start :hour-start :day-start :month-start
+                                :year-start)))
+             (list nil -1 zone)))))
+
+(defun org-timestamp-to-local (timestamp &optional zone)
+  "Return a copy of TIMESTAMP converted to ZONE (default local time).
+
+The date and time fields become the date and time shown in ZONE at the
+same point(s) in time, and the explicit zone is removed.  Both ends of a
+range are converted; a time range that crosses midnight in ZONE becomes a
+date range.  ZONE is anything accepted by `encode-time' \(nil for local
+time, t for UTC, an integer offset, or a zone name).
+
+A TIMESTAMP without an explicit zone is returned unchanged, so its time
+never depends on the local zone unless the caller asks for it."
+  (if (not (or (org-element-property :zone-start timestamp)
+               (org-element-property :zone-end timestamp)))
+      timestamp
+    (let* ((with-time (and (org-timestamp-has-time-p timestamp) t))
+           (range-type (org-element-property :range-type timestamp))
+           (copy (org-element-copy timestamp))
+           (start (decode-time (org-timestamp-to-time timestamp) zone))
+           (end (decode-time (org-timestamp-to-time timestamp t) zone)))
+      ;; Start.
+      (org-element-put-property copy :year-start (nth 5 start))
+      (org-element-put-property copy :month-start (nth 4 start))
+      (org-element-put-property copy :day-start (nth 3 start))
+      (org-element-put-property copy :hour-start (and with-time (nth 2 start)))
+      (org-element-put-property copy :minute-start (and with-time (nth 1 start)))
+      ;; End.  A non-range timestamp copies its start, as the parser does.
+      (let ((e (if range-type end start)))
+        (org-element-put-property copy :year-end (nth 5 e))
+        (org-element-put-property copy :month-end (nth 4 e))
+        (org-element-put-property copy :day-end (nth 3 e))
+        (org-element-put-property copy :hour-end (and with-time (nth 2 e)))
+        (org-element-put-property copy :minute-end (and with-time (nth 1 e))))
+      ;; A time range may cross midnight in ZONE and become a date range.
+      (when (and (eq range-type 'timerange)
+                 (not (and (= (org-element-property :year-start copy)
+                              (org-element-property :year-end copy))
+                           (= (org-element-property :month-start copy)
+                              (org-element-property :month-end copy))
+                           (= (org-element-property :day-start copy)
+                              (org-element-property :day-end copy)))))
+        (org-element-put-property copy :range-type 'daterange))
+      (org-element-put-property copy :zone-start nil)
+      (org-element-put-property copy :zone-end nil)
+      ;; Force the interpreter to recompute the raw value from properties.
+      (org-element-put-property copy :raw-value nil)
+      copy)))
 
 (defun org-timestamp-has-time-p (timestamp)
   "Non-nil when TIMESTAMP has a time specified."
diff --git a/testing/lisp/test-org-element.el b/testing/lisp/test-org-element.el
index 4628867ae..434fe3192 100644
--- a/testing/lisp/test-org-element.el
+++ b/testing/lisp/test-org-element.el
@@ -4289,6 +4289,76 @@ DEADLINE: <2012-03-29 thu.> SCHEDULED: <2012-03-29 thu.> CLOSED: [2012-03-29 thu
                                  :hour-start 17 :minute-start 30
                                  :hour-end 18 :minute-end 30)) nil))))
 
+(ert-deftest test-org-element/timestamp-parser-zone ()
+  "Test parsing IANA/offset zone specs in `timestamp' objects."
+  ;; No zone: both zone properties are nil.
+  (org-test-with-temp-text "<2026-07-19 Sun 12:00>"
+    (let ((ts (org-element-context)))
+      (should-not (org-element-property :zone-start ts))
+      (should-not (org-element-property :zone-end ts))))
+  ;; Single IANA zone.
+  (org-test-with-temp-text "<2026-07-19 Sun 12:00 @Europe/Kyiv>"
+    (let ((ts (org-element-context)))
+      (should (equal "Europe/Kyiv" (org-element-property :zone-start ts)))
+      (should-not (org-element-property :zone-end ts))))
+  ;; Zone with a repeater.
+  (org-test-with-temp-text "<2026-07-19 Sun 12:00 @Europe/Kyiv +1w>"
+    (let ((ts (org-element-context)))
+      (should (equal "Europe/Kyiv" (org-element-property :zone-start ts)))
+      (should (eq 'cumulate (org-element-property :repeater-type ts)))))
+  ;; Numeric offsets.
+  (org-test-with-temp-text "<2026-07-19 Sun 12:00 @+02>"
+    (should (equal "+02" (org-element-property :zone-start (org-element-context)))))
+  (org-test-with-temp-text "<2026-07-19 Sun 12:00 @+0230>"
+    (should (equal "+0230" (org-element-property :zone-start (org-element-context)))))
+  (org-test-with-temp-text "<2026-07-19 Sun 12:00 @-05:00>"
+    (should (equal "-05:00" (org-element-property :zone-start (org-element-context)))))
+  ;; Time range shares the start zone; end zone stays nil.
+  (org-test-with-temp-text "<2026-07-19 Sun 12:00-13:00 @Europe/Kyiv>"
+    (let ((ts (org-element-context)))
+      (should (equal "Europe/Kyiv" (org-element-property :zone-start ts)))
+      (should-not (org-element-property :zone-end ts))))
+  ;; Cross-zone date range: independent start and end zones.
+  (org-test-with-temp-text
+      "<2026-07-19 Sun 12:00 @Europe/Kyiv>--<2026-07-19 Sun 14:00 @Europe/Warsaw>"
+    (let ((ts (org-element-context)))
+      (should (equal "Europe/Kyiv" (org-element-property :zone-start ts)))
+      (should (equal "Europe/Warsaw" (org-element-property :zone-end ts))))))
+
+(ert-deftest test-org-element/timestamp-interpreter-zone ()
+  "Test that zones are written back correctly by the `timestamp' interpreter."
+  ;; Single IANA zone.
+  (should
+   (string-match "<2026-07-19 .* 12:00 @Europe/Kyiv>"
+		 (org-test-parse-and-interpret "<2026-07-19 Sun 12:00 @Europe/Kyiv>")))
+  ;; Zone precedes the repeater.
+  (should
+   (string-match "<2026-07-19 .* 12:00 @Europe/Kyiv \\+1w>"
+		 (org-test-parse-and-interpret "<2026-07-19 Sun 12:00 @Europe/Kyiv +1w>")))
+  ;; Numeric offset.
+  (should
+   (string-match "<2026-07-19 .* 12:00 @\\+0230>"
+		 (org-test-parse-and-interpret "<2026-07-19 Sun 12:00 @+0230>")))
+  ;; Time range: zone after the HH:MM-HH:MM range.
+  (should
+   (string-match "<2026-07-19 .* 12:00-13:00 @Europe/Kyiv>"
+		 (org-test-parse-and-interpret "<2026-07-19 Sun 12:00-13:00 @Europe/Kyiv>")))
+  ;; Cross-zone date range: each bracket keeps its own zone.
+  (should
+   (string-match
+    "<2026-07-19 .* 12:00 @Europe/Kyiv>--<2026-07-19 .* 14:00 @Europe/Warsaw>"
+    (org-test-parse-and-interpret
+     "<2026-07-19 Sun 12:00 @Europe/Kyiv>--<2026-07-19 Sun 14:00 @Europe/Warsaw>")))
+  ;; Zone survives on a manually constructed object.
+  (should
+   (string-match
+    "<2026-07-19 .* 12:00 @Europe/Kyiv>"
+    (org-element-timestamp-interpreter
+     '(timestamp
+       (:type active :year-start 2026 :month-start 7 :day-start 19
+	      :hour-start 12 :minute-start 0 :zone-start "Europe/Kyiv"))
+     nil))))
+
 (ert-deftest test-org-element/verse-block-interpreter ()
   "Test verse block interpretation."
   (should
diff --git a/testing/lisp/test-org.el b/testing/lisp/test-org.el
index 68942ff3e..a58e8c5da 100644
--- a/testing/lisp/test-org.el
+++ b/testing/lisp/test-org.el
@@ -39,17 +39,6 @@
 
 ;;; Helpers
 
-(defmacro org-test-with-timezone (tz &rest body)
-  "Evaluate BODY with TZ environment temporary set to the passed value."
-  (declare (indent 1))
-  (org-with-gensyms (tz-saved)
-    `(let ((,tz-saved (getenv "TZ")))
-       (unwind-protect
-           (progn
-             (setenv "TZ" ,tz)
-             ,@body)
-             (setenv "TZ" ,tz-saved)))))
-
 (defmacro org-test-with-result (result &rest body)
   "Evaluate BODY, and return buffer content based on RESULT.
 RESULT is an sexp, and is processed according to the following
@@ -291,7 +280,45 @@ Otherwise, evaluate RESULT as an sexp and return its result."
              "2022-03-31 23:33:00 +0200 CEST"
              (format-time-string
               "%F %T %z %Z"
-              (org-time-string-to-time "2022-03-31 23:33"))))))
+              (org-time-string-to-time "2022-03-31 23:33")))))
+  ;; An explicit "@" zone is used instead of the
+  ;; local zone.
+  (org-test-with-timezone "UTC"
+    ;; A zone-less timestamp uses local time (UTC here).
+    (should (string-equal
+             "2026-07-19 12:00:00 +0000"
+             (format-time-string
+              "%F %T %z"
+              (org-time-string-to-time "<2026-07-19 12:00>") t)))
+    ;; Explicit UTC zone.
+    (should (string-equal
+             "2026-07-19 12:00:00 +0000"
+             (format-time-string
+              "%F %T %z"
+              (org-time-string-to-time "<2026-07-19 12:00 @UTC>") t)))
+    ;; Numeric offset is subtracted to reach UTC.
+    (should (string-equal
+             "2026-07-19 10:00:00 +0000"
+             (format-time-string
+              "%F %T %z"
+              (org-time-string-to-time "<2026-07-19 12:00 @+02:00>") t)))
+    (should (string-equal
+             "2026-07-19 17:00:00 +0000"
+             (format-time-string
+              "%F %T %z"
+              (org-time-string-to-time "<2026-07-19 12:00 @-05:00>") t))))
+  ;; An IANA zone name is looked up in the system time zone database.  Use
+  ;; Asia/Tokyo, a stable +09:00 with no DST, so the result does not depend
+  ;; on the local zone or DST rules.
+  (should (time-equal-p
+           (org-time-string-to-time "<2026-07-19 12:00 @Asia/Tokyo>")
+           (org-time-string-to-time "<2026-07-19 12:00 @+09:00>")))
+  (org-test-with-timezone "America/New_York"
+    (should (string-equal
+             "2026-07-19 03:00:00 +0000"
+             (format-time-string
+              "%F %T %z"
+              (org-time-string-to-time "<2026-07-19 12:00 @Asia/Tokyo>") t)))))
 
 (ert-deftest test-org/org-read-date ()
   "Test `org-read-date' specifications."
@@ -391,7 +418,23 @@ Otherwise, evaluate RESULT as an sexp and return its result."
   (should (equal (org-parse-time-string "<2012-03-29>")
 		 '(0 0 0 29 3 2012 nil -1 nil)))
   (should (equal (org-parse-time-string "<2012-03-29>" t)
-		 '(0 nil nil 29 3 2012 nil -1 nil))))
+		 '(0 nil nil 29 3 2012 nil -1 nil)))
+  ;; An "@" zone is stored in the zone slot of the decoded time.
+  ;; An IANA zone name is kept as written.
+  (should (equal (org-parse-time-string "<2012-03-29 16:40 @Europe/Kyiv>")
+		 '(0 40 16 29 3 2012 nil -1 "Europe/Kyiv")))
+  (should (equal (org-parse-time-string "<2012-03-29 16:40 @UTC>")
+		 '(0 40 16 29 3 2012 nil -1 "UTC")))
+  ;; A numeric offset is converted to seconds east of UTC.
+  (should (equal (org-parse-time-string "<2012-03-29 16:40 @+02:00>")
+		 '(0 40 16 29 3 2012 nil -1 7200)))
+  (should (equal (org-parse-time-string "<2012-03-29 16:40 @+0230>")
+		 '(0 40 16 29 3 2012 nil -1 9000)))
+  (should (equal (org-parse-time-string "<2012-03-29 16:40 @-05:00>")
+		 '(0 40 16 29 3 2012 nil -1 -18000)))
+  ;; A zone on a date-only timestamp is still recognized.
+  (should (equal (org-parse-time-string "<2012-03-29 @UTC>")
+		 '(0 0 0 29 3 2012 nil -1 "UTC"))))
 
 (ert-deftest test-org/closest-date ()
   "Test `org-closest-date' specifications."
@@ -10141,7 +10184,14 @@ Behavior can be modified by setting `org-log-into-drawer', by keywords in
     (org-element-interpret-data
      (org-timestamp-from-time
       (org-time-string-to-time "<2012-03-29 Thu 16:40>")
-      nil t)))))
+      nil t))))
+  ;; When optional argument ZONE is non-nil, store it as :zone-start.
+  (should
+   (equal "Europe/Kyiv"
+	  (org-element-property
+	   :zone-start
+	   (org-timestamp-from-time
+	    (org-time-string-to-time "<2012-03-29 Thu 16:40>") t nil "Europe/Kyiv")))))
 
 (ert-deftest test-org/timestamp-to-time ()
   "Test `org-timestamp-to-time' specifications."
@@ -10197,6 +10247,83 @@ Behavior can be modified by setting `org-log-into-drawer', by keywords in
 	   "%Y-%m-%d"
 	   (org-timestamp-to-time
 	    (org-timestamp-from-string "[2012-03-29 Thu]--[2014-03-04 Tue]")
+	    t))))
+  ;; An explicit IANA zone gives the correct point in time regardless of
+  ;; the local zone.
+  (should
+   (equal "2026-07-19T09:00:00+0000"
+	  (format-time-string
+	   "%Y-%m-%dT%H:%M:%S%z"
+	   (org-timestamp-to-time
+	    (org-timestamp-from-string "<2026-07-19 Sun 12:00 @Europe/Kyiv>"))
+	   t)))
+  ;; Numeric offset.
+  (should
+   (equal "2026-07-19T09:30:00+0000"
+	  (format-time-string
+	   "%Y-%m-%dT%H:%M:%S%z"
+	   (org-timestamp-to-time
+	    (org-timestamp-from-string "<2026-07-19 Sun 12:00 @+0230>"))
+	   t)))
+  ;; The same clock time in two zones differs by one hour in summer
+  ;; (Kyiv is UTC+3, Warsaw UTC+2).
+  (should
+   (= 3600
+      (- (float-time
+	  (org-timestamp-to-time
+	   (org-timestamp-from-string "<2026-07-19 Sun 12:00 @Europe/Warsaw>")))
+	 (float-time
+	  (org-timestamp-to-time
+	   (org-timestamp-from-string "<2026-07-19 Sun 12:00 @Europe/Kyiv>"))))))
+  ;; Cross-zone date range: END uses the end zone.
+  (should
+   (equal "2026-07-19T12:00:00+0000"
+	  (format-time-string
+	   "%Y-%m-%dT%H:%M:%S%z"
+	   (org-timestamp-to-time
+	    (org-timestamp-from-string
+	     "<2026-07-19 Sun 12:00 @Europe/Kyiv>--<2026-07-19 Sun 14:00 @Europe/Warsaw>")
+	    t)
+	   t))))
+
+(ert-deftest test-org/timestamp-to-local ()
+  "Test `org-timestamp-to-local' specifications."
+  ;; A zone-less timestamp is already local: return it unchanged.
+  (let ((ts (org-timestamp-from-string "<2026-08-22 Sat 12:00>")))
+    (should (eq ts (org-timestamp-to-local ts))))
+  (org-test-with-timezone "Europe/Kyiv"
+    ;; A zoned time-of-day is converted to the local zone.
+    (should
+     (equal "<2026-08-22 Sat 11:00>"
+	    (org-element-interpret-data
+	     (org-timestamp-to-local
+	      (org-timestamp-from-string "<2026-08-22 Sat 04:00 @America/New_York>")))))
+    ;; It can fall on the next local day.
+    (should
+     (equal "<2026-08-23 Sun 05:00>"
+	    (org-element-interpret-data
+	     (org-timestamp-to-local
+	      (org-timestamp-from-string "<2026-08-22 Sat 22:00 @America/New_York>")))))
+    ;; Both ends of a date range are converted.
+    (should
+     (equal "<2026-08-23 Sun 05:00>--<2026-08-23 Sun 09:00>"
+	    (org-element-interpret-data
+	     (org-timestamp-to-local
+	      (org-timestamp-from-string
+	       "<2026-08-22 Sat 22:00 @America/New_York>--<2026-08-23 Sun 02:00 @America/New_York>"))))))
+  ;; A time range that crosses local midnight becomes a date range.
+  (org-test-with-timezone "Asia/Dubai"
+    (should
+     (equal "<2026-08-22 Sat 23:30>--<2026-08-23 Sun 00:30>"
+	    (org-element-interpret-data
+	     (org-timestamp-to-local
+	      (org-timestamp-from-string "<2026-08-22 Sat 21:30-22:30 @Europe/Warsaw>"))))))
+  ;; An explicit ZONE argument overrides the local zone.
+  (should
+   (equal "<2026-08-22 Sat 16:00>"
+	  (org-element-interpret-data
+	   (org-timestamp-to-local
+	    (org-timestamp-from-string "<2026-08-22 Sat 12:00 @America/New_York>")
 	    t)))))
 
 
diff --git a/testing/org-test.el b/testing/org-test.el
index 2928fa08c..9163c0836 100644
--- a/testing/org-test.el
+++ b/testing/org-test.el
@@ -502,6 +502,17 @@ Load all test files first."
     (ert "\\(org\\|ob\\|ox\\)"))
   (org-test-kill-all-examples))
 
+(defmacro org-test-with-timezone (tz &rest body)
+  "Evaluate BODY with TZ environment temporary set to the passed value."
+  (declare (indent 1))
+  (org-with-gensyms (tz-saved)
+    `(let ((,tz-saved (getenv "TZ")))
+       (unwind-protect
+           (progn
+             (setenv "TZ" ,tz)
+             ,@body)
+         (setenv "TZ" ,tz-saved)))))
+
 (defmacro org-test-at-time (time &rest body)
   "Run body while pretending that the current time is TIME.
 TIME can be a non-nil Lisp time value, or a string specifying a date and time."
-- 
2.54.0

From 678ad62b657d047cd031208b2d89751a88851d02 Mon Sep 17 00:00:00 2001
From: Dmytro Kostiuchenko <[email protected]>
Date: Tue, 25 Aug 2026 08:25:17 +0200
Subject: [PATCH 2/2] org-agenda: Honor timestamp time zones

Place and show agenda entries by the time zone written in their
timestamps, converting each to the local zone.  Zone-less timestamps are
unchanged.

* lisp/org-agenda.el (org-agenda-get-timestamps): Match the rendered day
plus two days on each side and keep only entries whose local day is the
rendered day; show each zoned time in the local zone.  Evaluate every
repeater occurrence in its own zone, so a DST change places it on the
right local day.
(org-agenda-get-deadlines, org-agenda-get-scheduled)
(org-agenda--planning-raw): Convert SCHEDULED and DEADLINE to local time.
(org-agenda-get-blocks): Convert date ranges to local time.
(org-agenda--timestamp-at-day, org-agenda--zoned-occurrence-day)
(org-agenda--zoned-repeat-day, org-agenda--zoned-repeat-boundary)
(org-agenda--zoned-repeat-visible-p): New helpers.
* testing/lisp/test-org-agenda.el: Add tests.
---
 lisp/org-agenda.el              | 306 ++++++++++++++++++++++++++------
 testing/lisp/test-org-agenda.el | 114 ++++++++++++
 2 files changed, 363 insertions(+), 57 deletions(-)

diff --git a/lisp/org-agenda.el b/lisp/org-agenda.el
index a426d4f5b..bb4e0e8b6 100644
--- a/lisp/org-agenda.el
+++ b/lisp/org-agenda.el
@@ -5804,6 +5804,111 @@ This function is invoked if `org-agenda-todo-ignore-deadlines',
 			(match-string 1) org-agenda-todo-ignore-timestamp))
 		      (t))))))))))
 
+(defun org-agenda--timestamp-at-day (timestamp abs-day)
+  "Return a copy of TIMESTAMP shifted so its start date is ABS-DAY.
+ABS-DAY is an absolute day number as used by the calendar.  The end of a
+range is shifted by the same amount, so its length is preserved.  The
+time-of-day and zone are kept, so this yields another occurrence of a
+repeater."
+  (let* ((copy (org-element-copy timestamp))
+	 (base (calendar-absolute-from-gregorian
+		(list (org-element-property :month-start timestamp)
+		      (org-element-property :day-start timestamp)
+		      (org-element-property :year-start timestamp))))
+	 (delta (- abs-day base)))
+    (pcase-let ((`(,m ,d ,y) (calendar-gregorian-from-absolute abs-day)))
+      (org-element-put-property copy :year-start y)
+      (org-element-put-property copy :month-start m)
+      (org-element-put-property copy :day-start d))
+    (when (org-element-property :year-end timestamp)
+      (pcase-let ((`(,m ,d ,y)
+		   (calendar-gregorian-from-absolute
+		    (+ delta (calendar-absolute-from-gregorian
+			      (list (org-element-property :month-end timestamp)
+				    (org-element-property :day-end timestamp)
+				    (org-element-property :year-end timestamp)))))))
+	(org-element-put-property copy :year-end y)
+	(org-element-put-property copy :month-end m)
+	(org-element-put-property copy :day-end d)))
+    (org-element-put-property copy :raw-value nil)
+    copy))
+
+(defun org-agenda--zoned-occurrence-day (timestamp abs-day)
+  "Return the local day TIMESTAMP falls on when its date is set to ABS-DAY.
+The result is the absolute day it falls on in the local zone, which
+differs from ABS-DAY when the zone offset crosses local midnight,
+e.g. 22:00 America/New_York is the next day in Europe/Kyiv."
+  (pcase-let ((`(,month ,day ,year) (calendar-gregorian-from-absolute abs-day)))
+    (time-to-days
+     (org-encode-time
+      (list 0
+	    (or (org-element-property :minute-start timestamp) 0)
+	    (or (org-element-property :hour-start timestamp) 0)
+	    day month year nil -1
+	    (org-timestamp--encode-zone
+	     (org-element-property :zone-start timestamp)))))))
+
+(defun org-agenda--zoned-repeat-day (timestamp repeat current pos)
+  "Return the written day of a REPEAT occurrence of TIMESTAMP on local day CURRENT.
+Return nil when no occurrence falls on CURRENT.  A zone offset moves an
+occurrence by at most two calendar days, so only nearby written dates are
+tried; each is checked on its own, so a DST change that varies the offset
+is handled per occurrence."
+  (seq-find
+   (lambda (w)
+     (and (= w (org-agenda--timestamp-to-absolute repeat w 'past (current-buffer) pos))
+	  (= current (org-agenda--zoned-occurrence-day timestamp w))))
+   (number-sequence (- current 2) (+ current 2))))
+
+(defun org-agenda--zoned-repeat-boundary (timestamp repeat today dir pos)
+  "Return a boundary occurrence's local day for a zoned repeater.
+With DIR `past', return the latest occurrence local day on or before
+TODAY; with DIR `future', the earliest one after TODAY.  TIMESTAMP is the
+base timestamp and REPEAT its raw string."
+  (let* ((base (calendar-absolute-from-gregorian
+		(list (org-element-property :month-start timestamp)
+		      (org-element-property :day-start timestamp)
+		      (org-element-property :year-start timestamp))))
+	 ;; Approximate the written day matching TODAY, then correct exactly.
+	 (shift (- (time-to-days (org-timestamp-to-time timestamp)) base))
+	 (w (org-agenda--timestamp-to-absolute
+	     repeat (- today shift) 'past (current-buffer) pos))
+	 (next (lambda (from) (org-agenda--timestamp-to-absolute
+			       repeat (1+ from) 'future (current-buffer) pos)))
+	 (prev (lambda (from) (org-agenda--timestamp-to-absolute
+			       repeat (1- from) 'past (current-buffer) pos))))
+    ;; Step back until the occurrence is on or before TODAY.
+    (while (> (org-agenda--zoned-occurrence-day timestamp w) today)
+      (setq w (funcall prev w)))
+    (pcase dir
+      (`past
+       ;; Advance while the next occurrence is still on or before TODAY.
+       (let (n)
+	 (while (and (> (setq n (funcall next w)) w)
+		     (<= (org-agenda--zoned-occurrence-day timestamp n) today))
+	   (setq w n)))
+       (org-agenda--zoned-occurrence-day timestamp w))
+      (`future
+       ;; Advance to the first occurrence after TODAY.
+       (while (<= (org-agenda--zoned-occurrence-day timestamp w) today)
+	 (setq w (funcall next w)))
+       (org-agenda--zoned-occurrence-day timestamp w)))))
+
+(defun org-agenda--zoned-repeat-visible-p (timestamp repeat current today todo-state pos)
+  "Whether a zoned repeater occurrence on local day CURRENT should show.
+Mirror the display policy of `org-agenda-prefer-last-repeat' and
+`org-agenda-show-future-repeats', but in local-day space."
+  (cond
+   ((<= current today)
+    (if (or (eq org-agenda-prefer-last-repeat t)
+	    (member todo-state org-agenda-prefer-last-repeat))
+	(= current (org-agenda--zoned-repeat-boundary timestamp repeat today 'past pos))
+      t))
+   ((not org-agenda-show-future-repeats) nil)
+   ((eq org-agenda-show-future-repeats 'next)
+    (= current (org-agenda--zoned-repeat-boundary timestamp repeat today 'future pos)))
+   (t t)))
+
 (defun org-agenda-get-timestamps (&optional deadlines)
   "Return the date stamp information for agenda display.
 Optional argument DEADLINES is a list of deadline items to be
@@ -5818,25 +5923,44 @@ displayed in agenda view."
 		      (format "mouse-2 or RET jump to Org file %s"
 			      (abbreviate-file-name buffer-file-name))))
 	 (current (calendar-absolute-from-gregorian date))
+	 (date-string
+	  (format-time-string
+	   "%Y-%m-%d"
+	   (org-encode-time 0 0 0
+			    (calendar-extract-day date)
+			    (calendar-extract-month date)
+			    (calendar-extract-year date))))
 	 (today (org-today))
 	 (deadline-position-alist
 	  (mapcar (lambda (d)
 		    (let ((m (get-text-property 0 'org-hd-marker d)))
 		      (and m (marker-position m))))
 		  deadlines))
-	 ;; Match timestamps set to current date, timestamps with
-	 ;; a repeater, and S-exp timestamps.
+	 ;; Match timestamps on the current date, timestamps with a
+	 ;; repeater, and S-exp timestamps.
+	 ;; A zoned timestamp can land up to 2 days off its written date in
+	 ;; local time, so also match the 4 neighbouring dates; the wrong
+	 ;; ones are dropped.
+	 ;; Offsetting the day and re-encoding rolls month/year over, e.g.
+	 ;; for 2026-12-31 the date part is
+	 ;;   <\(?:2026-12-29\|2026-12-30\|2026-12-31\|2027-01-01\|2027-01-02\)
 	 (regexp
 	  (concat
 	   (if org-agenda-include-inactive-timestamps "[[<]" "<")
-	   (regexp-quote
-	    (format-time-string
-             "%Y-%m-%d" ; We do not use `org-time-stamp-format' to not demand day name in timestamps.
-             (org-encode-time   ; DATE bound by calendar
-              0 0 0
-              (calendar-extract-day date)
-              (calendar-extract-month date)
-              (calendar-extract-year date))))
+	   "\\(?:"
+	   (mapconcat
+	    (lambda (offset)
+	      (regexp-quote
+	       (format-time-string
+	        "%Y-%m-%d" ; We do not use `org-time-stamp-format' to not demand day name in timestamps.
+	        (org-encode-time    ; DATE bound by calendar
+	         0 0 0
+	         (+ offset (calendar-extract-day date))
+	         (calendar-extract-month date)
+	         (calendar-extract-year date)))))
+	    (number-sequence -2 2)
+	    "\\|")
+	   "\\)"
 	   "\\|\\(<[0-9]+-[0-9]+-[0-9]+[^>\n]+?\\+[0-9]+[hdwmy]>\\)"
 	   "\\|\\(<%%\\(([^>\n]+)\\)\\([^\n>]*\\)>\\)"))
 	 timestamp-items)
@@ -5864,48 +5988,82 @@ displayed in agenda view."
 			      (goto-char pos)
 			      (looking-at org-ts-regexp-both)
 			      (match-string 0))))
+	       ;; A zone-less timestamp is local already.
+	       ;; A zoned timestamp must be converted to local time zone.
+	       (zoned? (and (not sexp-entry) (string-match-p "@" timestamp)))
+	       (ts-object (and zoned?
+			       (ignore-errors (org-timestamp-from-string timestamp))))
 	       (todo-state (org-get-todo-state))
 	       (warntime (org-entry-get (point) "APPT_WARNTIME" 'selective))
-	       (done? (member todo-state org-done-keywords)))
+	       (done? (member todo-state org-done-keywords))
+	       ;; Written day of the zoned repeater occurrence shown on
+	       ;; CURRENT, set below and used for the local time-of-day.
+	       occurrence-day)
+	  ;; REPEAT is set only when regexp matched its repeater branch.
+	  ;; regexp matches the plain date first, and that date can be a
+	  ;; repeater's own date, so REPEAT stays nil.
+	  ;; Detect the repeater (e.g. "+1d") and set REPEAT.
+	  (when (and (not repeat) (not sexp-entry)
+		     (string-match-p "[.+]?\\+[0-9]+[hdwmy]" timestamp))
+	    (setq repeat timestamp))
 	  ;; Possibly skip done tasks.
 	  (when (and done? org-agenda-skip-timestamp-if-done)
 	    (throw :skip t))
 	  ;; S-exp entry doesn't match current day: skip it.
 	  (when (and sexp-entry (not (org-diary-sexp-entry sexp-entry "" date)))
 	    (throw :skip nil))
+	  ;; Show a non-repeating timestamp on its local day.
+	  ;; A zone-less timestamp is on its written date.
+	  ;; A zoned timestamp is on the local day it falls on.
+	  (when (and (not repeat) (not sexp-entry))
+	    (unless (if ts-object
+			(= current (time-to-days (org-timestamp-to-time ts-object)))
+		      (string-match-p (concat "\\`[[<]" (regexp-quote date-string))
+				      timestamp))
+	      (throw :skip nil)))
 	  (when repeat
-	    (let* ((past
-		    ;; A repeating time stamp is shown at its base
-		    ;; date and every repeated date up to TODAY.  If
-		    ;; `org-agenda-prefer-last-repeat' is non-nil,
-		    ;; however, only the last repeat before today
-		    ;; (inclusive) is shown.
-		    (org-agenda--timestamp-to-absolute
-		     repeat
-		     (if (or (> current today)
-			     (eq org-agenda-prefer-last-repeat t)
-			     (member todo-state org-agenda-prefer-last-repeat))
-			 today
-		       current)
-		     'past (current-buffer) pos))
-		   (future
-		    ;;  Display every repeated date past TODAY
-		    ;;  (exclusive) unless
-		    ;;  `org-agenda-show-future-repeats' is nil.  If
-		    ;;  this variable is set to `next', only display
-		    ;;  the first repeated date after TODAY
-		    ;;  (exclusive).
-		    (cond
-		     ((<= current today) past)
-		     ((not org-agenda-show-future-repeats) past)
-		     (t
-		      (let ((base (if (eq org-agenda-show-future-repeats 'next)
-				      (1+ today)
-				    current)))
-			(org-agenda--timestamp-to-absolute
-			 repeat base 'future (current-buffer) pos))))))
-	      (when (and (/= current past) (/= current future))
-		(throw :skip nil))))
+	    (if ts-object
+		;; Zoned repeater: evaluate each occurrence in its own zone,
+		;; so a DST change that moves an occurrence to another local
+		;; day is placed correctly.
+		(let ((w (org-agenda--zoned-repeat-day ts-object repeat current pos)))
+		  (unless (and w (org-agenda--zoned-repeat-visible-p
+				  ts-object repeat current today todo-state pos))
+		    (throw :skip nil))
+		  (setq occurrence-day w))
+	      ;; Zone-less repeater: occurrence dates are already local.
+	      (let* ((past
+		      ;; A repeating time stamp is shown at its base
+		      ;; date and every repeated date up to TODAY.  If
+		      ;; `org-agenda-prefer-last-repeat' is non-nil,
+		      ;; however, only the last repeat before today
+		      ;; (inclusive) is shown.
+		      (org-agenda--timestamp-to-absolute
+		       repeat
+		       (if (or (> current today)
+			       (eq org-agenda-prefer-last-repeat t)
+			       (member todo-state org-agenda-prefer-last-repeat))
+			   today
+			 current)
+		       'past (current-buffer) pos))
+		     (future
+		      ;;  Display every repeated date past TODAY
+		      ;;  (exclusive) unless
+		      ;;  `org-agenda-show-future-repeats' is nil.  If
+		      ;;  this variable is set to `next', only display
+		      ;;  the first repeated date after TODAY
+		      ;;  (exclusive).
+		      (cond
+		       ((<= current today) past)
+		       ((not org-agenda-show-future-repeats) past)
+		       (t
+			(let ((base (if (eq org-agenda-show-future-repeats 'next)
+					(1+ today)
+				      current)))
+			  (org-agenda--timestamp-to-absolute
+			   repeat base 'future (current-buffer) pos))))))
+		(when (and (/= current past) (/= current future))
+		  (throw :skip nil)))))
 	  (save-excursion
 	    (re-search-backward org-outline-regexp-bol nil t)
 	    ;; Possibly skip timestamp when a deadline is set.
@@ -5930,13 +6088,26 @@ displayed in agenda view."
 			      (match-string 1)))
 		   (inactive? (= (char-after pos) ?\[))
 		   (habit? (and (fboundp 'org-is-habit-p) (org-is-habit-p)))
+		   ;; When the timestamp carries an explicit zone, show its
+		   ;; time-of-day in the local zone.  For a repeater use the
+		   ;; occurrence shown on CURRENT, whose local time can differ
+		   ;; from the base across a DST change.
+		   (dotime (cond
+			    (occurrence-day
+			     (org-element-interpret-data
+			      (org-timestamp-to-local
+			       (org-agenda--timestamp-at-day ts-object occurrence-day))))
+			    (ts-object
+			     (org-element-interpret-data
+			      (org-timestamp-to-local ts-object)))
+			    (t timestamp)))
 		   (item
 		    (org-agenda-format-item
 		     (and inactive? org-agenda-inactive-leader)
                      (org-add-props head nil
                        'effort effort
                        'effort-minutes effort-minutes)
-                     level category tags timestamp org-ts-regexp habit?)))
+                     level category tags dotime org-ts-regexp habit?)))
 	      (org-add-props item props
 		'urgency (if habit?
                              (org-habit-get-urgency (org-habit-parse-todo))
@@ -6326,6 +6497,18 @@ See also the user option `org-agenda-clock-consistency-checks'."
       ;; Nope, this gap is not OK
       nil)))
 
+(defun org-agenda--planning-raw (timestamp)
+  "Return TIMESTAMP's raw value without its enclosing brackets.
+A zoned TIMESTAMP is first converted to local time with
+`org-timestamp-to-local', so the deadline and scheduled collectors place
+and show it by local day and time.  A zone-less TIMESTAMP is returned as
+written."
+  (if (or (org-element-property :zone-start timestamp)
+	  (org-element-property :zone-end timestamp))
+      (substring (org-element-interpret-data (org-timestamp-to-local timestamp))
+		 1 -1)
+    (substring (org-element-property :raw-value timestamp) 1 -1)))
+
 (defun org-agenda-get-deadlines (&optional with-hour)
   "Return the deadline information for agenda display.
 When WITH-HOUR is non-nil, only return deadlines with an hour
@@ -6363,10 +6546,7 @@ specification like [h]h:mm."
          (goto-char (org-element-contents-begin el))
          (catch :skip
 	   (org-agenda-skip el)
-	   (let* ((s (substring (org-element-property
-                                 :raw-value
-                                 (org-element-property :deadline el))
-                                1 -1))
+	   (let* ((s (org-agenda--planning-raw (org-element-property :deadline el)))
 	          (pos (save-excursion
                          (goto-char (org-element-contents-begin el))
                          ;; We intentionally leave NOERROR
@@ -6569,10 +6749,7 @@ scheduled items with an hour specification like [h]h:mm."
          (goto-char (org-element-contents-begin el))
          (catch :skip
            (org-agenda-skip el)
-           (let* ((s (substring (org-element-property
-                                 :raw-value
-                                 (org-element-property :scheduled el))
-                                1 -1))
+           (let* ((s (org-agenda--planning-raw (org-element-property :scheduled el)))
                   (pos (save-excursion
                          (goto-char (org-element-contents-begin el))
                          ;; We intentionally leave NOERROR
@@ -6804,8 +6981,23 @@ scheduled items with an hour specification like [h]h:mm."
 	(org-agenda-skip)
 	(setq pos (point))
         (setq inactive? (eq ?\[ (char-after (match-beginning 0))))
-	(let ((start-time (match-string 1))
-	      (end-time (match-string 2)))
+	(let* ((start-time (match-string 1))
+	       (end-time (match-string 2))
+	       ;; Show the range in local time: START-TIME-LOCAL and
+	       ;; END-TIME-LOCAL are the start and end in local time (same as
+	       ;; the originals when there is no zone).  The originals are
+	       ;; used for the day span and for `remove-re', which matches the
+	       ;; buffer text.
+	       (start-time-local
+		(if (string-match-p "@" start-time)
+		    (org-agenda--planning-raw
+		     (org-timestamp-from-string (concat "<" start-time ">")))
+		  start-time))
+	       (end-time-local
+		(if (string-match-p "@" end-time)
+		    (org-agenda--planning-raw
+		     (org-timestamp-from-string (concat "<" end-time ">")))
+		  end-time)))
 	  (setq start-day (time-to-days
 		           (condition-case err
 			       (org-time-string-to-time start-time)
@@ -6891,11 +7083,11 @@ scheduled items with an hour specification like [h]h:mm."
                                level category tags
 			       (cond
                                 ((and (= start-day agenda-today) (= end-day agenda-today))
-			         (concat "<" start-time ">--<" end-time ">"))
+			         (concat "<" start-time-local ">--<" end-time-local ">"))
                                 ((= start-day agenda-today)
-			         (concat "<" start-time ">"))
+			         (concat "<" start-time-local ">"))
 			        ((= end-day agenda-today)
-			         (concat "<" end-time ">")))
+			         (concat "<" end-time-local ">")))
 			       remove-re)))))
 	      (org-add-props txt props
                 'face face
diff --git a/testing/lisp/test-org-agenda.el b/testing/lisp/test-org-agenda.el
index 9af012b7d..9136e9258 100644
--- a/testing/lisp/test-org-agenda.el
+++ b/testing/lisp/test-org-agenda.el
@@ -70,6 +70,120 @@
     (should (= 2 (count-lines (point-min) (point-max)))))
   (org-test-agenda--kill-all-agendas))
 
+(ert-deftest test-org-agenda/timestamp-zone ()
+  "Zoned timestamps are placed and shown in the local time zone."
+  (cl-assert (not org-agenda-sticky) nil "precondition violation")
+  (cl-assert (not (org-test-agenda--agenda-buffers))
+	     nil "precondition violation")
+  (org-test-with-timezone "Europe/Kyiv"
+    (org-test-at-time "2026-08-22 08:00"
+      (org-test-agenda-with-agenda
+	  (concat
+	   "* TODO NY morning\n<2026-08-22 Sat 04:00 @America/New_York>\n"
+	   "* TODO NY late\n<2026-08-22 Sat 22:00 @America/New_York>\n"
+	   "* TODO local noon\n<2026-08-22 Sat 12:00>\n"
+	   "* TODO repeated\n<2026-08-22 Sat 22:00-22:45 @America/New_York +1d>\n")
+	(let ((org-agenda-prefix-format '((agenda . " %-12t "))))
+	  (org-agenda-list nil "2026-08-22" 2))
+	(set-buffer org-agenda-buffer-name)
+	(let ((text (buffer-substring-no-properties (point-min) (point-max))))
+	  ;; 04:00 New York -> 11:00 Kyiv, still the written day.
+	  (should (string-match-p "11:00.*NY morning" text))
+	  ;; A zone-less timestamp is shown as written.
+	  (should (string-match-p "12:00.*local noon" text))
+	  ;; 22:00 New York -> 05:00 Kyiv, on the next day.
+	  (should (string-match-p "5:00.*NY late" text))
+	  ;; The repeater's first occurrence is 05:00-05:45 on the next day.
+	  (should (string-match-p "5:00-5:45.*repeated" text))
+	  ;; NY morning falls before, NY late after, the second day's header.
+	  (should (< (string-match "NY morning" text)
+		     (string-match "23 August 2026" text)))
+	  (should (< (string-match "23 August 2026" text)
+		     (string-match "NY late" text)))))))
+  (org-test-agenda--kill-all-agendas))
+
+(ert-deftest test-org-agenda/planning-zone ()
+  "Zoned SCHEDULED and DEADLINE use the local day and time."
+  (cl-assert (not org-agenda-sticky) nil "precondition violation")
+  (cl-assert (not (org-test-agenda--agenda-buffers))
+	     nil "precondition violation")
+  (org-test-with-timezone "Europe/Kyiv"
+    (org-test-at-time "2026-08-22 08:00"
+      (org-test-agenda-with-agenda
+	  (concat
+	   "* TODO sched morning\nSCHEDULED: <2026-08-22 Sat 04:00 @America/New_York>\n"
+	   "* TODO sched late\nSCHEDULED: <2026-08-22 Sat 22:00 @America/New_York>\n"
+	   "* TODO deadline late\nDEADLINE: <2026-08-22 Sat 22:00 @America/New_York>\n")
+	(let ((org-agenda-prefix-format '((agenda . " %-12t ")))
+	      (org-deadline-warning-days 0))
+	  (org-agenda-list nil "2026-08-22" 2))
+	(set-buffer org-agenda-buffer-name)
+	(let ((text (buffer-substring-no-properties (point-min) (point-max))))
+	  ;; 04:00 New York -> 11:00 Kyiv, still the written day.
+	  (should (string-match-p "11:00.*sched morning" text))
+	  ;; 22:00 New York -> 05:00 Kyiv, on the next day.
+	  (should (string-match-p "5:00.*sched late" text))
+	  (should (string-match-p "5:00.*deadline late" text))
+	  (should (< (string-match "sched morning" text)
+		     (string-match "23 August 2026" text)))
+	  (should (< (string-match "23 August 2026" text)
+		     (string-match "sched late" text)))
+	  (should (< (string-match "23 August 2026" text)
+		     (string-match "deadline late" text)))))))
+  (org-test-agenda--kill-all-agendas))
+
+(ert-deftest test-org-agenda/daterange-zone ()
+  "Zoned date ranges span and show local days and times."
+  (cl-assert (not org-agenda-sticky) nil "precondition violation")
+  (cl-assert (not (org-test-agenda--agenda-buffers))
+	     nil "precondition violation")
+  (org-test-with-timezone "Europe/Kyiv"
+    (org-test-at-time "2026-08-22 08:00"
+      (org-test-agenda-with-agenda
+	  (concat
+	   "* TODO multiday range\n"
+	   "<2026-08-22 Sat 22:00 @America/New_York>--<2026-08-24 Mon 08:00 @America/New_York>\n"
+	   "* TODO collapsing range\n"
+	   "<2026-08-22 Sat 22:00 @America/New_York>--<2026-08-23 Sun 02:00 @America/New_York>\n")
+	(let ((org-agenda-prefix-format '((agenda . " %-12t "))))
+	  (org-agenda-list nil "2026-08-22" 4))
+	(set-buffer org-agenda-buffer-name)
+	(let ((text (buffer-substring-no-properties (point-min) (point-max))))
+	  ;; 22:00 New York start -> 05:00 Kyiv, on the next day.
+	  (should (string-match-p "5:00.*multiday range" text))
+	  ;; 08:00 New York end -> 15:00 Kyiv, two days later.
+	  (should (string-match-p "15:00.*multiday range" text))
+	  ;; A range that lands on one local day collapses to a time range.
+	  (should (string-match-p "5:00-9:00.*collapsing range" text))
+	  (should (< (string-match "23 August 2026" text)
+		     (string-match "multiday range" text)))))))
+  (org-test-agenda--kill-all-agendas))
+
+(ert-deftest test-org-agenda/repeat-zone-dst ()
+  "A zoned repeater is placed per occurrence across a DST change."
+  (cl-assert (not org-agenda-sticky) nil "precondition violation")
+  (cl-assert (not (org-test-agenda--agenda-buffers))
+	     nil "precondition violation")
+  (org-test-with-timezone "Europe/Kyiv"
+    (org-test-at-time "2026-03-15 08:00"
+      (org-test-agenda-with-agenda
+	  "* TODO nydaily\n<2026-03-01 Sun 17:30 @America/New_York +1d>\n"
+	(let ((org-agenda-prefix-format '((agenda . " %-12t "))))
+	  (org-agenda-list nil "2026-03-05" 8))
+	(set-buffer org-agenda-buffer-name)
+	(let* ((text (buffer-substring-no-properties (point-min) (point-max)))
+	       (p8 (string-match "8 March 2026" text))
+	       (p9 (string-match "9 March 2026" text)))
+	  (should p8)
+	  (should p9)
+	  ;; US spring-forward is 2026-03-08.  Before it, 17:30 New York is
+	  ;; 00:30 the next day in Kyiv; from it, 23:30 the same day.  Each
+	  ;; day still shows the repeater exactly once.
+	  (should (string-match "0:30[^\n]*nydaily" text p8))
+	  (should (< (string-match "0:30[^\n]*nydaily" text p8) p9))
+	  (should (string-match "23:30[^\n]*nydaily" text p9))))))
+  (org-test-agenda--kill-all-agendas))
+
 (ert-deftest test-org-agenda/one-line ()
   "One informative line in the agenda."
   (cl-assert (not org-agenda-sticky) nil "precondition violation")
-- 
2.54.0

Reply via email to