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