Hello org maintainers,

I know this has been discussed before, probably more than once. In
particular, there was a January/February 2023 thread starting from
"[FEATURE REQUEST] Timezone support in org-mode datestamps and
org-agenda", followed by discussion of proposed timestamp syntax:

https://lists.gnu.org/archive/html/emacs-orgmode/2023-01/msg00313.html
https://lists.gnu.org/archive/html/emacs-orgmode/2023-01/msg01025.html

I would nevertheless like to raise the timezone issue again, this time
with a proof of concept implementation.

A disclaimer about my perspective: I have spent a lot of time building
task schedulers and systems that need to treat time zones, DST
transitions, floating times, and absolute instants carefully.

However, I am a very lightweight Emacs and Org user (and better
timezone handling is one of the things that could change that ;).
Thus, I have no prior experience with Emacs Lips, I don't know the
conventions and internals of either project. I relied heavily on LLMs
to implement this change.


* The change overview

The change adds explicit IANA timezone support to Org timestamps while
preserving the current timestamp API and the default interpretation of
existing Org files.

The change focuses on supporting the following expressions:

#+begin_src org
<2026-07-19 Sun 12:00 Europe/Kyiv>
<2026-07-19 Sun 12:00-13:00 Europe/Kyiv>
<2026-07-19 Sun 12:00 Europe/Kyiv>--<2026-07-19 Sun 14:00 Europe/Warsaw>
<2026-07-19 Sun 12:00 Europe/Kyiv +1w>
#+end_src

Today, the Org parser accepts these expressions because timestamp
regexps are loose about trailing timestamp content. However, the
detailed timestamp object does not keep the timezone as structured data
and conversion to an Emacs time value uses the ambient local timezone.

The cornerstone of my change is a new internal normalized
representation for timestamp:

#+begin_src elisp
(:instant TIME
 :zone ZONE
 :effective-zone EFFECTIVE-ZONE
 :granularity GRANULARITY)
#+end_src

where:
- =:instant= is the resolved Emacs time value,
- =:zone= is the explicit IANA zone written by the user, or nil,
- =:effective-zone= is the zone used to encode/decode the instant. It
  matches =:zone= when =:zone= is specified; otherwise it is derived
  from the environment, and
- =:granularity= captures whether the timestamp is date-only or
  contains time.

I think this is a better model for timestamps. As far as I can tell,
any Org timestamp shape known to me can be expressed using this
structure. On top of that:
- agenda sorting across multiple time zones can compare `instant'
  values directly;
- elapsed duration and clock checks can subtract `instant' values;
- iCalendar sync and other task-manager APIs can receive both a
  canonical instant and the source timezone/TZID;
- formatting and timestamp editing can preserve whether the user wrote
  an explicit zone.

However, the current timestamp object is part of Org's public API, so
I did not try to replace it. The POC keeps the existing public object
and normalizes it to the structure above internally.

This change:
- keeps existing zone-less timestamp semantics. Zone-less timestamps
  remain floating and continue to be interpreted in the ambient local
  timezone when conversion to an Emacs time value is needed;
- does not replace public `org-element' timestamp objects with a new
  struct, only extends them with `:zone-start' and `:zone-end';
- intentionally does not support timezone abbreviations such as `CET',
  `CEST', or `EST', or numeric offset syntax. Timezone abbreviations
  are ambiguous, and numeric offsets do not carry daylight-saving
  transition rules.

Is there a chance this design can land in main?
Please let me know what you think.

Best regards,
Dmytro
—
em-dashes are my own
From 6854937533e4d225cf3e2cbddd48b04d83419e24 Mon Sep 17 00:00:00 2001
From: Dmytro Kostiuchenko <[email protected]>
Date: Tue, 21 Jul 2026 01:23:03 +0200
Subject: [PATCH] [WIP] Add support for IANA timezones

---
 lisp/org-agenda.el                | 229 +++++++++++++++-------
 lisp/org-element.el               |  44 ++++-
 lisp/org-timestamp.el             | 305 ++++++++++++++++++++++++++++++
 lisp/org.el                       | 191 ++++++++++++-------
 lisp/ox-icalendar.el              |  20 +-
 testing/lisp/test-org-agenda.el   |  90 +++++++++
 testing/lisp/test-org-element.el  |  69 +++++++
 testing/lisp/test-org.el          | 124 ++++++++++++
 testing/lisp/test-ox-icalendar.el |  15 ++
 9 files changed, 931 insertions(+), 156 deletions(-)
 create mode 100644 lisp/org-timestamp.el

diff --git a/lisp/org-agenda.el b/lisp/org-agenda.el
index 707444b8a..8702f14f1 100644
--- a/lisp/org-agenda.el
+++ b/lisp/org-agenda.el
@@ -5516,16 +5516,66 @@ function from a program - use `org-agenda-get-day-entries' instead."
 
 ;;; Agenda entry finders
 
-(defun org-agenda--timestamp-to-absolute (&rest args)
-  "Call `org-time-string-to-absolute' with ARGS.
+(defun org-agenda--timestamp-to-absolute (timestamp &rest args)
+  "Return absolute day number for TIMESTAMP.
+TIMESTAMP may be a parsed timestamp object or a timestamp string.
 However, throw `:skip' whenever an error is raised."
   (condition-case e
-      (apply #'org-time-string-to-absolute args)
+      (if (and (consp timestamp) (eq (car timestamp) 'timestamp) (not args))
+          (org-timestamp--boundary-to-absolute
+           (org-timestamp--normalize-boundary timestamp))
+        (apply #'org-time-string-to-absolute
+               (if (and (consp timestamp) (eq (car timestamp) 'timestamp))
+                   (substring (org-element-property :raw-value timestamp) 1 -1)
+                 timestamp)
+               args))
     (org-diary-sexp-no-match (throw :skip nil))
     (error
      (message "%s; Skipping entry" (error-message-string e))
      (throw :skip nil))))
 
+(defun org-agenda--timestamp-to-local-absolute (timestamp &rest args)
+  "Return agenda-local absolute day number for TIMESTAMP.
+TIMESTAMP may be a parsed timestamp object or a timestamp string.
+When ARGS are provided, delegate to `org-time-string-to-absolute'
+for recurring and diary timestamp handling."
+  (condition-case e
+      (if (and (consp timestamp) (eq (car timestamp) 'timestamp) (not args))
+          (calendar-absolute-from-gregorian
+           (org-timestamp--boundary-local-calendar-date
+            (org-timestamp--normalize-boundary timestamp)))
+        (apply #'org-time-string-to-absolute
+               (if (and (consp timestamp) (eq (car timestamp) 'timestamp))
+                   (substring (org-element-property :raw-value timestamp) 1 -1)
+                 timestamp)
+               args))
+    (org-diary-sexp-no-match (throw :skip nil))
+    (error
+     (message "%s; Skipping entry" (error-message-string e))
+     (throw :skip nil))))
+
+(defun org-agenda--timestamp-time-of-day-string (timestamp)
+  "Return agenda display time for TIMESTAMP, or nil if it has no time.
+TIMESTAMP must be a parsed timestamp object.  Explicit-zoned
+timestamps are represented by their instant in the agenda's local
+timezone."
+  (when (org-element-property :hour-start timestamp)
+    (let ((start (format-time-string
+                  "%H:%M"
+                  (org-timestamp--boundary-to-time
+                   (org-timestamp--normalize-boundary timestamp)))))
+      (concat
+       " "
+       start
+       (when (eq (org-element-property :range-type timestamp) 'timerange)
+         (concat
+          "-"
+          (format-time-string
+           "%H:%M"
+           (org-timestamp--boundary-to-time
+            (org-timestamp--normalize-boundary timestamp t)))))
+       " "))))
+
 (defun org-agenda-get-day-entries (file date &rest args)
   "Does the work for `org-diary' and `org-agenda'.
 FILE is the path to a file to be checked for entries.  DATE is date like
@@ -5836,6 +5886,9 @@ displayed in agenda view."
               (calendar-extract-day date)
               (calendar-extract-month date)
               (calendar-extract-year date))))
+           "\\|\\(?:[0-9]+-[0-9]+-[0-9]+[^>\n]*"
+           org-timestamp--iana-zone-regexp
+           "[^>\n]*>\\)"
 	   "\\|\\(<[0-9]+-[0-9]+-[0-9]+[^>\n]+?\\+[0-9]+[hdwmy]>\\)"
 	   "\\|\\(<%%\\(([^>\n]+)\\)\\([^\n>]*\\)>\\)"))
 	 timestamp-items)
@@ -5863,6 +5916,13 @@ displayed in agenda view."
 			      (goto-char pos)
 			      (looking-at org-ts-regexp-both)
 			      (match-string 0))))
+               (timestamp-object
+                (unless sexp-entry
+                  (save-excursion
+                    (goto-char pos)
+                    (let ((context (org-element-context)))
+                      (and (org-element-type-p context 'timestamp)
+                           context)))))
 	       (todo-state (org-get-todo-state))
 	       (warntime (org-entry-get (point) "APPT_WARNTIME" 'selective))
 	       (done? (member todo-state org-done-keywords)))
@@ -5872,6 +5932,12 @@ displayed in agenda view."
 	  ;; 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))
+          (when (and timestamp-object
+                     (not repeat)
+                     (/= current
+                         (org-agenda--timestamp-to-local-absolute
+                          timestamp-object)))
+            (throw :skip nil))
 	  (when repeat
 	    (let* ((past
 		    ;; A repeating time stamp is shown at its base
@@ -5879,14 +5945,26 @@ displayed in agenda view."
 		    ;; `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))
+                    (if timestamp-object
+                        (org-timestamp--closest-repeater-date
+                         (org-timestamp--normalize-boundary timestamp-object)
+                         repeat
+                         (if (or (> current today)
+                                 (eq org-agenda-prefer-last-repeat t)
+                                 (member todo-state
+                                         org-agenda-prefer-last-repeat))
+                             today
+                           current)
+                         'past
+                         'local)
+                      (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
@@ -5901,8 +5979,12 @@ displayed in agenda view."
 		      (let ((base (if (eq org-agenda-show-future-repeats 'next)
 				      (1+ today)
 				    current)))
-			(org-agenda--timestamp-to-absolute
-			 repeat base 'future (current-buffer) pos))))))
+                        (if timestamp-object
+                            (org-timestamp--closest-repeater-date
+                             (org-timestamp--normalize-boundary timestamp-object)
+                             repeat base 'future 'local)
+			  (org-agenda--timestamp-to-absolute
+			   repeat base 'future (current-buffer) pos)))))))
 	      (when (and (/= current past) (/= current future))
 		(throw :skip nil))))
 	  (save-excursion
@@ -5935,7 +6017,12 @@ displayed in agenda view."
                      (org-add-props head nil
                        'effort effort
                        'effort-minutes effort-minutes)
-                     level category tags timestamp org-ts-regexp habit?)))
+                     level category tags
+                     (or (and timestamp-object
+                              (org-agenda--timestamp-time-of-day-string
+                               timestamp-object))
+                         timestamp)
+                     org-ts-regexp habit?)))
 	      (org-add-props item props
 		'urgency (if habit?
                              (org-habit-get-urgency (org-habit-parse-todo))
@@ -5946,8 +6033,16 @@ displayed in agenda view."
 		'date date
 		'level level
                 'effort effort 'effort-minutes effort-minutes
-		'ts-date (if repeat (org-agenda--timestamp-to-absolute repeat)
-			   current)
+		'ts-date (cond
+                          (repeat
+                           (if timestamp-object
+                               (org-agenda--timestamp-to-local-absolute
+                                timestamp-object)
+                             (org-agenda--timestamp-to-absolute repeat)))
+                          (timestamp-object
+                           (org-agenda--timestamp-to-local-absolute
+                            timestamp-object))
+                          (t current))
 		'todo-state todo-state
 		'warntime warntime
 		'type "timestamp")
@@ -6362,10 +6457,8 @@ 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* ((ts (org-element-property :deadline el))
+                  (s (substring (org-element-property :raw-value ts) 1 -1))
 	          (pos (save-excursion
                          (goto-char (org-element-contents-begin el))
                          ;; We intentionally leave NOERROR
@@ -6388,9 +6481,10 @@ specification like [h]h:mm."
 		    (sexp? (org-agenda--timestamp-to-absolute s current))
 		    ((or (eq org-agenda-prefer-last-repeat t)
 		         (member todo-state org-agenda-prefer-last-repeat))
-		     (org-agenda--timestamp-to-absolute
-		      s today 'past (current-buffer) pos))
-		    (t (org-agenda--timestamp-to-absolute s))))
+		     (org-timestamp--closest-repeater-date
+                      (org-timestamp--normalize-boundary ts)
+                      s today 'past 'local))
+		    (t (org-agenda--timestamp-to-local-absolute ts))))
 	          ;; REPEAT is the future repeat closest from CURRENT,
 	          ;; according to `org-agenda-show-future-repeats'. If
 	          ;; the latter is nil, or if the time stamp has no
@@ -6404,15 +6498,14 @@ specification like [h]h:mm."
 		     (let ((base (if (eq org-agenda-show-future-repeats 'next)
 				     (1+ today)
 				   current)))
-		       (org-agenda--timestamp-to-absolute
-		        s base 'future (current-buffer) pos)))))
+		       (org-timestamp--closest-repeater-date
+                        (org-timestamp--normalize-boundary ts)
+                        s base 'future 'local)))))
 	          (diff (- deadline current))
 	          (max-warning-days
 		   (let ((scheduled
 		          (and org-agenda-skip-deadline-prewarning-if-scheduled
-                               (org-element-property
-                                :raw-value
-                                (org-element-property :scheduled el)))))
+                               (org-element-property :scheduled el))))
 		     (cond
 		      ((not scheduled) most-positive-fixnum)
 		      ;; The current item has a scheduled date, so
@@ -6424,7 +6517,7 @@ specification like [h]h:mm."
 			   'pre-scheduled)
 		       ;; Set pre-warning to no earlier than SCHEDULED.
 		       (min (- deadline
-			       (org-agenda--timestamp-to-absolute scheduled))
+			       (org-agenda--timestamp-to-local-absolute scheduled))
 			    org-deadline-warning-days))
 		      ;; Set pre-warning to deadline.
 		      (t 0))))
@@ -6471,8 +6564,7 @@ specification like [h]h:mm."
 		        ;; No time of day designation if it is only
 		        ;; a reminder.
 		        ((and (/= current deadline) (/= current repeat)) nil)
-		        ((string-match " \\([012]?[0-9]:[0-9][0-9]\\)" s)
-		         (concat (substring s (match-beginning 1)) " "))
+		        ((org-agenda--timestamp-time-of-day-string ts))
 		        (t 'time)))
 		      (item
 		       (org-agenda-format-item
@@ -6568,10 +6660,8 @@ 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* ((ts (org-element-property :scheduled el))
+                  (s (substring (org-element-property :raw-value ts) 1 -1))
                   (pos (save-excursion
                          (goto-char (org-element-contents-begin el))
                          ;; We intentionally leave NOERROR
@@ -6594,9 +6684,10 @@ scheduled items with an hour specification like [h]h:mm."
 		    (sexp? (org-agenda--timestamp-to-absolute s current))
 		    ((or (eq org-agenda-prefer-last-repeat t)
 		         (member todo-state org-agenda-prefer-last-repeat))
-		     (org-agenda--timestamp-to-absolute
-		      s today 'past (current-buffer) pos))
-		    (t (org-agenda--timestamp-to-absolute s))))
+		     (org-timestamp--closest-repeater-date
+                      (org-timestamp--normalize-boundary ts)
+                      s today 'past 'local))
+		    (t (org-agenda--timestamp-to-local-absolute ts))))
 	          ;; REPEAT is the future repeat closest from CURRENT,
 	          ;; according to `org-agenda-show-future-repeats'. If
 	          ;; the latter is nil, or if the time stamp has no
@@ -6610,8 +6701,9 @@ scheduled items with an hour specification like [h]h:mm."
 		     (let ((base (if (eq org-agenda-show-future-repeats 'next)
 				     (1+ today)
 				   current)))
-		       (org-agenda--timestamp-to-absolute
-		        s base 'future (current-buffer) pos)))))
+		       (org-timestamp--closest-repeater-date
+                        (org-timestamp--normalize-boundary ts)
+                        s base 'future 'local)))))
 	          (diff (- current schedule))
 	          (warntime (org-entry-get (point) "APPT_WARNTIME" 'selective))
 	          (pastschedp (< schedule today))
@@ -6620,9 +6712,7 @@ scheduled items with an hour specification like [h]h:mm."
                                (string= "habit" (org-element-property :STYLE el))))
 	          (max-delay-days
 		   (let ((deadline (and org-agenda-skip-scheduled-delay-if-deadline
-                                        (org-element-property
-                                         :raw-value
-                                         (org-element-property :deadline el)))))
+                                        (org-element-property :deadline el))))
 		     (cond
 		      ((not deadline) most-positive-fixnum)
 		      ;; The current item has a deadline date, so
@@ -6634,7 +6724,7 @@ scheduled items with an hour specification like [h]h:mm."
 			   'post-deadline)
 		       ;; Set delay to no later than DEADLINE.
 		       (min (- schedule
-			       (org-agenda--timestamp-to-absolute deadline))
+			       (org-agenda--timestamp-to-local-absolute deadline))
 			    org-scheduled-delay-days))
 		      (t 0))))
 	          (delay-days
@@ -6642,7 +6732,8 @@ scheduled items with an hour specification like [h]h:mm."
 		    ;; Nullify delay when a repeater triggered already
 		    ;; and the delay is of the form --Xd.
 		    ((and (string-match-p "--[0-9]+[hdwmy]" s)
-		          (> schedule (org-agenda--timestamp-to-absolute s)))
+		          (> schedule (org-agenda--timestamp-to-local-absolute
+                                       ts)))
 		     0)
 		    (t (min max-delay-days (org-get-wdays s t))))))
 	     ;; Display scheduled items at base date (SCHEDULE), today if
@@ -6673,12 +6764,11 @@ scheduled items with an hour specification like [h]h:mm."
                        (eq org-agenda-skip-scheduled-if-deadline-is-shown
                            'repeated-after-deadline))
                (let ((deadline
-                      (time-to-days
-                       (when (org-element-property :deadline el)
-                         (org-time-string-to-time
-                          (org-element-interpret-data
-                           (org-element-property :deadline el)))))))
-		 (when (and (or (<= (org-agenda--timestamp-to-absolute s) deadline)
+                      (when (org-element-property :deadline el)
+                        (org-agenda--timestamp-to-local-absolute
+                         (org-element-property :deadline el)))))
+		 (when (and deadline
+                            (or (<= (org-agenda--timestamp-to-absolute s) deadline)
                                 (not (= schedule current)))
                             (> current deadline))
                    (throw :skip nil))))
@@ -6735,8 +6825,7 @@ scheduled items with an hour specification like [h]h:mm."
 		          (/= current schedule)
 		          (/= current repeat))
 		         nil)
-		        ((string-match " \\([012]?[0-9]:[0-9][0-9]\\)" s)
-		         (concat (substring s (match-beginning 1)) " "))
+		        ((org-agenda--timestamp-time-of-day-string ts))
 		        (t 'time)))
 		      (item
 		       (org-agenda-format-item
@@ -6805,26 +6894,24 @@ scheduled items with an hour specification like [h]h:mm."
         (setq inactive? (eq ?\[ (char-after (match-beginning 0))))
 	(let ((start-time (match-string 1))
 	      (end-time (match-string 2)))
-	  (setq start-day (time-to-days
-		           (condition-case err
-			       (org-time-string-to-time start-time)
+	  (setq start-day (condition-case err
+                              (org-agenda--timestamp-to-absolute start-time)
+		            (error
 		             (error
-		              (error
-			       "Bad timestamp %S at %d in buffer %S\nError was: %s"
-			       start-time
-			       pos
-			       (current-buffer)
-			       (error-message-string err)))))
-		end-day (time-to-days
-		         (condition-case err
-			     (org-time-string-to-time end-time)
+			      "Bad timestamp %S at %d in buffer %S\nError was: %s"
+			      start-time
+			      pos
+			      (current-buffer)
+			      (error-message-string err))))
+		end-day (condition-case err
+                            (org-agenda--timestamp-to-absolute end-time)
+		          (error
 		           (error
-		            (error
-			     "Bad timestamp %S at %d in buffer %S\nError was: %s"
-			     end-time
-			     pos
-                             (current-buffer)
-			     (error-message-string err))))))
+			    "Bad timestamp %S at %d in buffer %S\nError was: %s"
+			    end-time
+			    pos
+                            (current-buffer)
+			    (error-message-string err)))))
 	  (when (and (> (- agenda-today start-day) -1)
                      (> (- end-day agenda-today) -1))
             ;; Only allow days between the limits, because the normal
diff --git a/lisp/org-element.el b/lisp/org-element.el
index 58c65ad08..ba43e7673 100644
--- a/lisp/org-element.el
+++ b/lisp/org-element.el
@@ -74,6 +74,7 @@
 (require 'org-list)
 (require 'org-macs)
 (require 'org-table)
+(require 'org-timestamp)
 (require 'org-fold-core)
 
 (declare-function org-escape-code-in-string "org-src" (s))
@@ -4446,12 +4447,21 @@ Assume point is at the beginning of the timestamp."
 	     (post-blank (progn (goto-char (match-end 0))
 				(skip-chars-forward " \t")))
 	     (end (point))
+             (date-start-zone (unless diaryp
+                                (org-timestamp--split-zone date-start)))
+             (date-end-zone (unless diaryp
+                              (org-timestamp--split-zone date-end)))
+             (zone-start (cdr date-start-zone))
+             (zone-end (cdr date-end-zone))
 	     (time-range
 	      (when (string-match
 		     "[012]?[0-9]:[0-5][0-9]\\(-\\([012]?[0-9]\\):\\([0-5][0-9]\\)\\)"
-		     date-start)
-	        (cons (string-to-number (match-string 2 date-start))
-		      (string-to-number (match-string 3 date-start)))))
+		     (if diaryp date-start (car date-start-zone)))
+	        (cons
+                 (string-to-number
+                  (match-string 2 (if diaryp date-start (car date-start-zone))))
+		 (string-to-number
+                  (match-string 3 (if diaryp date-start (car date-start-zone)))))))
 	     (type (cond (diaryp 'diary)
 			 ((and activep (or date-end time-range)) 'active-range)
 			 (activep 'active)
@@ -4506,6 +4516,8 @@ Assume point is at the beginning of the timestamp."
 	     month-end day-end hour-end minute-end)
 	;; Parse date-start.
 	(unless diaryp
+          (setq date-start (car date-start-zone)
+                date-end (car date-end-zone))
 	  (let ((date (org-parse-time-string date-start t)))
 	    (setq year-start (decoded-time-year date)
 		  month-start (decoded-time-month date)
@@ -4543,11 +4555,15 @@ Assume point is at the beginning of the timestamp."
 		      :day-start day-start
 		      :hour-start hour-start
 		      :minute-start minute-start
+                      :zone-start zone-start
 		      :year-end year-end
 		      :month-end month-end
 		      :day-end day-end
 		      :hour-end hour-end
 		      :minute-end minute-end
+                      :zone-end (or zone-end
+                                    (and (eq range-type 'timerange)
+                                         zone-start))
 		      :begin begin
 		      :end end
 		      :post-blank post-blank)
@@ -4593,6 +4609,7 @@ 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 (org-element-property :zone-start timestamp))
                (brackets
                 (if (member
                      type
@@ -4600,11 +4617,15 @@ Assume point is at the beginning of the timestamp."
                     (cons "[" "]")
                   ;; diary as well
                   (cons "<" ">")))
-               (timestamp-end
+               (timestamp-close
                 (concat
                  (and (org-string-nw-p repeat-string) (concat " " repeat-string))
                  (and (org-string-nw-p warning-string) (concat " " warning-string))
-                 (cdr brackets))))
+                 (cdr brackets)))
+               (timestamp-end
+                (concat
+                 (and zone-start (concat " " zone-start))
+                 timestamp-close)))
           (concat
            ;; Opening bracket: [ or <
            (car brackets)
@@ -4627,7 +4648,10 @@ Assume point is at the beginning of the timestamp."
 	       day-start month-start year-start)))
            ;; 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)))
+                 (minute-end (org-element-property :minute-end timestamp))
+                 (zone-end (or (org-element-property :zone-end timestamp)
+                               (and (eq range-type 'timerange)
+                                    zone-start))))
              (pcase type
                ((or `active `inactive)
                 ;; `org-element-timestamp-parser' uses this type
@@ -4685,10 +4709,14 @@ 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)))
+                    (and zone-end (concat " " zone-end))
+                    timestamp-close))))))
            ;; repeater + warning + closing > or ]
            ;; This info is duplicated in date ranges.
-           timestamp-end))))))
+           (unless (and (memq type '(active-range inactive-range))
+                        (or (eq range-type 'daterange) (null range-type)))
+             timestamp-end)))))))
 ;;;; Underline
 
 (defun org-element-underline-parser ()
diff --git a/lisp/org-timestamp.el b/lisp/org-timestamp.el
new file mode 100644
index 000000000..cb1393638
--- /dev/null
+++ b/lisp/org-timestamp.el
@@ -0,0 +1,305 @@
+;;; org-timestamp.el --- Timestamp time helpers for Org  -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Free Software Foundation, Inc.
+
+;; Author: Carsten Dominik <[email protected]>
+;; Maintainer: Ihor Radchenko <[email protected]>
+;; Keywords: outlines, hypermedia, calendar, text
+;; URL: https://orgmode.org
+
+;; This file is part of GNU Emacs.
+
+;; GNU Emacs is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; GNU Emacs is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with GNU Emacs.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; Internal helpers for timestamp parsing and time conversion.
+
+;;; Code:
+
+(require 'calendar)
+(require 'cl-lib)
+(require 'org-compat)
+(require 'org-macs)
+(require 'time-date)
+
+(declare-function org-element-property "org-element-ast" (property node))
+(defvar org-extend-today-until)
+
+(defconst org-timestamp--iana-zone-regexp
+  (rx (seq
+       (any alpha "_")
+       (* (any alnum "_+-"))
+       "/"
+       (any alnum "_")
+       (* (any alnum "_+-."))
+       (* "/" (any alnum "_") (* (any alnum "_+-.")))))
+  "Regular expression matching an IANA timezone name.")
+
+(defun org-timestamp--valid-zone-p (zone)
+  "Non-nil when ZONE names a timezone accepted by `org-encode-time'."
+  (and (stringp zone)
+       (condition-case nil
+           (progn (ignore (org-encode-time 0 0 0 1 1 2024 nil -1 zone)) t)
+         (error nil))))
+
+(defun org-timestamp--split-zone (s)
+  "Return (STRING . ZONE) from timestamp boundary string S.
+ZONE is nil when S does not contain a valid explicit IANA timezone."
+  (if (not (stringp s)) (cons s nil)
+    (let ((case-fold-search nil)
+          zone)
+      (with-temp-buffer
+        (insert s)
+        (goto-char (point-min))
+        (while (and (not zone)
+                    (re-search-forward org-timestamp--iana-zone-regexp nil t))
+          (let ((candidate (match-string 0)))
+            (when (org-timestamp--valid-zone-p candidate)
+              (setq zone candidate)
+              (replace-match "" t t))))
+        (cons (replace-regexp-in-string
+               "\\`[ \t\n\r]+\\|[ \t\n\r]+\\'" "" (buffer-string))
+              zone)))))
+
+(defun org-timestamp--boundary-effective-zone (timestamp &optional end)
+  "Return effective timezone for TIMESTAMP boundary.
+When END is non-nil, use the end boundary."
+  (or (org-element-property (if end :zone-end :zone-start) timestamp)
+      (and end
+           (eq (org-element-property :range-type timestamp) 'timerange)
+           (org-element-property :zone-start timestamp))))
+
+(defun org-timestamp--boundary-granularity (timestamp &optional end)
+  "Return granularity for TIMESTAMP boundary.
+When END is non-nil, use the end boundary."
+  (if (org-element-property (if end :hour-end :hour-start) timestamp)
+      'minute
+    'date))
+
+(defun org-timestamp--normalize-boundary (timestamp &optional end)
+  "Return normalized boundary for TIMESTAMP.
+When END is non-nil, normalize the end boundary.
+
+The return value is a plist containing `:instant', `:zone',
+`:effective-zone', and `:granularity'."
+  (let* ((zone (org-element-property (if end :zone-end :zone-start) timestamp))
+         (effective-zone (org-timestamp--boundary-effective-zone timestamp end))
+         (minute (or (org-element-property
+                      (if end :minute-end :minute-start) timestamp)
+                     0))
+         (hour (or (org-element-property
+                    (if end :hour-end :hour-start) timestamp)
+                   0))
+         (day (or (org-element-property
+                   (if end :day-end :day-start) timestamp)
+                  0))
+         (month (or (org-element-property
+                     (if end :month-end :month-start) timestamp)
+                    0))
+         (year (or (org-element-property
+                    (if end :year-end :year-start) timestamp)
+                   0)))
+    (list :instant
+          (org-encode-time 0 minute hour day month year nil -1 effective-zone)
+          :zone zone
+          :effective-zone effective-zone
+          :granularity (org-timestamp--boundary-granularity timestamp end))))
+
+(defun org-timestamp--string-to-boundary (s)
+  "Return normalized boundary for timestamp string S."
+  (let* ((split (org-timestamp--split-zone s))
+         (zone (cdr split))
+         (time (org-parse-time-string (car split)))
+         (minute (or (decoded-time-minute time) 0))
+         (hour (or (decoded-time-hour time) 0))
+         (day (or (decoded-time-day time) 0))
+         (month (or (decoded-time-month time) 0))
+         (year (or (decoded-time-year time) 0)))
+    (list :instant (org-encode-time 0 minute hour day month year nil -1 zone)
+          :zone zone
+          :effective-zone zone
+          :granularity (if (decoded-time-hour time) 'minute 'date))))
+
+(defun org-timestamp--boundary-to-time (boundary)
+  "Return internal time from normalized BOUNDARY."
+  (plist-get boundary :instant))
+
+(defun org-timestamp--boundary-to-decoded-time (boundary &optional zone)
+  "Decode normalized BOUNDARY.
+Use ZONE when non-nil.  Otherwise, use BOUNDARY's effective
+timezone."
+  (decode-time (org-timestamp--boundary-to-time boundary)
+               (or zone (plist-get boundary :effective-zone))))
+
+(defun org-timestamp--boundary-calendar-date (boundary &optional zone)
+  "Return Gregorian calendar date for normalized BOUNDARY.
+The result is decoded using ZONE when non-nil, or BOUNDARY's
+effective timezone otherwise."
+  (let ((decoded (org-timestamp--boundary-to-decoded-time boundary zone)))
+    (list (decoded-time-month decoded)
+          (decoded-time-day decoded)
+          (decoded-time-year decoded))))
+
+(defun org-timestamp--boundary-to-local-decoded-time (boundary)
+  "Decode normalized BOUNDARY in the ambient local timezone."
+  (decode-time (org-timestamp--boundary-to-time boundary)))
+
+(defun org-timestamp--boundary-local-calendar-date (boundary)
+  "Return Gregorian calendar date for BOUNDARY in ambient local timezone."
+  (let ((decoded (org-timestamp--boundary-to-local-decoded-time boundary)))
+    (list (decoded-time-month decoded)
+          (decoded-time-day decoded)
+          (decoded-time-year decoded))))
+
+(defun org-timestamp--boundary-to-absolute (boundary)
+  "Return absolute day number from normalized BOUNDARY."
+  (calendar-absolute-from-gregorian
+   (org-timestamp--boundary-calendar-date boundary)))
+
+(defun org-timestamp--encode-decoded-time (decoded zone)
+  "Encode DECODED as an internal time using ZONE."
+  (org-encode-time
+   (decoded-time-second decoded)
+   (decoded-time-minute decoded)
+   (decoded-time-hour decoded)
+   (decoded-time-day decoded)
+   (decoded-time-month decoded)
+   (decoded-time-year decoded)
+   nil -1 zone))
+
+(defun org-timestamp--shift-boundary (boundary n unit)
+  "Return BOUNDARY shifted by N calendar UNITs in its effective timezone.
+UNIT is one of `minute', `hour', `day', `month', or `year'."
+  (let* ((zone (plist-get boundary :effective-zone))
+         (decoded (org-timestamp--boundary-to-decoded-time boundary))
+         (keyword (cl-case unit
+                    (minute :minute)
+                    (hour :hour)
+                    (day :day)
+                    (month :month)
+                    (year :year)))
+         (shifted (org-decoded-time-add
+                   decoded
+                   (make-decoded-time keyword n))))
+    (plist-put (copy-sequence boundary)
+               :instant
+               (org-timestamp--encode-decoded-time shifted zone))))
+
+(defun org-timestamp--closest-repeater-date
+    (boundary repeater current prefer &optional local)
+  "Return closest repeated date for BOUNDARY near CURRENT.
+REPEATER is a timestamp repeater string containing +NUNIT.  CURRENT
+is an absolute day number.  PREFER is nil, `past', or `future',
+with the same meaning as in `org-closest-date'.
+
+When LOCAL is non-nil, project BOUNDARY into the ambient local
+timezone before expanding the repeater.  Otherwise, project it into
+its effective timezone."
+  (if (not (string-match "\\+\\([0-9]+\\)\\([hdwmy]\\)" repeater))
+      (calendar-absolute-from-gregorian
+       (if local
+           (org-timestamp--boundary-local-calendar-date boundary)
+         (org-timestamp--boundary-calendar-date boundary)))
+    (let ((value (string-to-number (match-string 1 repeater)))
+          (type (match-string 2 repeater)))
+      (if (= 0 value)
+          (calendar-absolute-from-gregorian
+           (if local
+               (org-timestamp--boundary-local-calendar-date boundary)
+             (org-timestamp--boundary-calendar-date boundary)))
+        (let* ((decoded (if local
+                            (org-timestamp--boundary-to-local-decoded-time
+                             boundary)
+                          (org-timestamp--boundary-to-decoded-time boundary)))
+               (base (list (decoded-time-month decoded)
+                           (decoded-time-day decoded)
+                           (decoded-time-year decoded)))
+               (target (calendar-gregorian-from-absolute current))
+               (sday (calendar-absolute-from-gregorian base))
+               (cday (calendar-absolute-from-gregorian target))
+               n1 n2)
+          (if (<= cday sday) sday
+            (pcase type
+              ("h"
+               (let ((missing-hours
+                      (mod (+ (- (* 24 (- cday sday))
+                                 (decoded-time-hour decoded))
+                              org-extend-today-until)
+                           value)))
+                 (setf n1 (if (= missing-hours 0) cday
+                            (- cday (1+ (/ missing-hours 24)))))
+                 (setf n2 (+ cday (/ (- value missing-hours) 24)))))
+              ((or "d" "w")
+               (let ((value (if (equal type "w") (* 7 value) value)))
+                 (setf n1 (+ sday (* value (/ (- cday sday) value))))
+                 (setf n2 (+ n1 value))))
+              ("m"
+               (let* ((add-months
+                       (lambda (d n)
+                         (let ((m (+ (calendar-extract-month d) n)))
+                           (list (mod m 12)
+                                 (calendar-extract-day d)
+                                 (+ (/ m 12)
+                                    (calendar-extract-year d))))))
+                      (months
+                       (* (/ (+ (* 12 (- (calendar-extract-year target)
+                                         (calendar-extract-year base)))
+                                (- (calendar-extract-month target)
+                                   (calendar-extract-month base))
+                                (if (> (calendar-extract-day target)
+                                       (calendar-extract-day base))
+                                    0 -1))
+                             value)
+                          value))
+                      (before (funcall add-months base months)))
+                 (setf n1 (calendar-absolute-from-gregorian before))
+                 (setf n2
+                       (calendar-absolute-from-gregorian
+                        (funcall add-months before value)))))
+              (_
+               (let* ((d (calendar-extract-day base))
+                      (m (calendar-extract-month base))
+                      (y (calendar-extract-year base))
+                      (years
+                       (* (/ (- (calendar-extract-year target)
+                                y
+                                (if (or (> (calendar-extract-month target) m)
+                                        (and (= (calendar-extract-month target) m)
+                                             (> (calendar-extract-day target) d)))
+                                    0
+                                  1))
+                             value)
+                          value))
+                      (before (list m d (+ y years))))
+                 (setf n1 (calendar-absolute-from-gregorian before))
+                 (setf n2
+                       (calendar-absolute-from-gregorian
+                        (list m d (+ (calendar-extract-year before)
+                                     value)))))))
+            (cond
+             ((eq prefer 'past)   (if (= cday n2) n2 n1))
+             ((eq prefer 'future) (if (= cday n1) n1 n2))
+             (t (if (> (abs (- cday n1)) (abs (- cday n2))) n2 n1)))))))))
+
+(defun org-timestamp--format-boundary (boundary format &optional utc)
+  "Format normalized BOUNDARY according to FORMAT.
+When UTC is non-nil, format in Universal Time."
+  (format-time-string format
+                      (org-timestamp--boundary-to-time boundary)
+                      (if utc t (plist-get boundary :effective-zone))))
+
+(provide 'org-timestamp)
+
+;;; org-timestamp.el ends here
diff --git a/lisp/org.el b/lisp/org.el
index 294a5eb52..6b4280d05 100644
--- a/lisp/org.el
+++ b/lisp/org.el
@@ -82,6 +82,7 @@
 (require 'calendar)
 (require 'find-func)
 (require 'format-spec)
+(require 'org-timestamp)
 (require 'thingatpt)
 
 (condition-case nil
@@ -468,10 +469,10 @@ 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-regexp2 (concat "<" org-ts-regexp1 "[^>\n]\\{0,64\\}>")
   "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 "[^]>\n]\\{0,64\\}[]>]")
   "Regular expression matching time stamps (also [..]), with groups.")
 
 (defconst org-tr-regexp (concat org-ts-regexp "--?-?" org-ts-regexp)
@@ -14189,6 +14190,7 @@ Return the position where this entry starts, or nil if there is no such entry."
 (defvar org-last-changed-timestamp nil)
 (defvar org-last-inserted-timestamp nil
   "The last time stamp inserted with `org-insert-timestamp'.")
+(defvar org-read-date-effective-zone nil)
 
 (defalias 'org-time-stamp #'org-timestamp)
 (defun org-timestamp (arg &optional inactive)
@@ -14221,6 +14223,9 @@ non-nil."
          (repeater (and ts
 			(string-match "\\([.+-]+[0-9]+[hdwmy] ?\\)+" ts)
 			(match-string 0 ts)))
+         (zone (and ts (save-match-data
+                         (cdr (org-timestamp--split-zone ts)))))
+         (org-read-date-effective-zone zone)
 	 org-time-was-given
 	 org-end-time-was-given
 	 (time
@@ -14249,7 +14254,11 @@ non-nil."
       (setq org-last-changed-timestamp
 	    (org-insert-timestamp
 	     time (or org-time-was-given arg)
-	     inactive nil nil (list org-end-time-was-given)))
+	     inactive nil nil
+             (org-timestamp--extra-with-zone
+              (list org-end-time-was-given)
+              (and (or org-time-was-given arg) zone))
+             (and (or org-time-was-given arg) zone)))
       (when repeater
 	(backward-char)
 	(insert " " repeater)
@@ -14382,7 +14391,7 @@ user."
 	    org-time-stamp-rounding-minutes))
 	 (ct (org-current-time))
 	 (org-def (or org-overriding-default-time default-time ct))
-	 (org-defdecode (decode-time org-def))
+	 (org-defdecode (decode-time org-def org-read-date-effective-zone))
          (cur-frame (selected-frame))
 	 (mouse-autoselect-window nil)	; Don't let the mouse jump
 	 (calendar-setup
@@ -14403,7 +14412,7 @@ user."
       (setq org-defdecode (decode-time org-def)))
     (let* ((timestr (format-time-string
 		     (if org-with-time "%Y-%m-%d %H:%M" "%Y-%m-%d")
-		     org-def))
+		     org-def org-read-date-effective-zone))
 	   (prompt (concat (if prompt (concat prompt " ") "")
 			   (format "Date+time [%s]: " timestr))))
       (cond
@@ -14421,7 +14430,10 @@ user."
             ;; the call to `calendar'.
 	    (with-current-buffer calendar-buffer (setq cursor-type nil))
 	    (unwind-protect
-		(let ((days (- (time-to-days org-def)
+		(let ((days (- (calendar-absolute-from-gregorian
+                                (list (decoded-time-month org-defdecode)
+                                      (decoded-time-day org-defdecode)
+                                      (decoded-time-year org-defdecode)))
 			       (calendar-absolute-from-gregorian
 				(calendar-current-date)))))
 		  (org-funcall-in-calendar #'calendar-forward-day t days)
@@ -14474,7 +14486,11 @@ user."
 		 "range representable on this machine"))
       (ding))
 
-    (setq final (org-encode-time final))
+    (setq final
+          (org-encode-time
+           (nth 0 final) (nth 1 final) (nth 2 final)
+           (nth 3 final) (nth 4 final) (nth 5 final)
+           nil -1 org-read-date-effective-zone))
 
     (setq org-read-date-final-answer ans)
 
@@ -14512,7 +14528,13 @@ user."
                        (and (boundp 'org-time-was-given) org-time-was-given))
                    org-read-date-inactive
                    org-display-custom-times))
-	     (txt (format-time-string fmt (org-encode-time f)))
+	     (txt (format-time-string
+                   fmt
+                   (org-encode-time
+                    (nth 0 f) (nth 1 f) (nth 2 f)
+                    (nth 3 f) (nth 4 f) (nth 5 f)
+                    nil -1 org-read-date-effective-zone)
+                   org-read-date-effective-zone))
 	     (txt (concat "=> " txt)))
 	(when (and org-end-time-was-given
 		   (string-match org-plain-time-of-day-regexp txt))
@@ -14943,30 +14965,50 @@ This is used by `org-read-date' in a temporary keymap for the calendar buffer."
     (when (active-minibuffer-window) (exit-minibuffer))))
 
 (defalias 'org-insert-time-stamp #'org-insert-timestamp)
-(defun org-insert-timestamp (time &optional with-hm inactive pre post extra)
+(defun org-timestamp--normalize-extra (extra)
+  "Normalize timestamp EXTRA string."
+  (when (listp extra)
+    (setq extra (car extra))
+    (if (and (stringp extra)
+	     (string-match "\\([0-9]+\\):\\([0-9]+\\)" extra))
+	(setq extra (format "-%02d:%02d"
+			    (string-to-number (match-string 1 extra))
+			    (string-to-number (match-string 2 extra))))
+      (setq extra nil)))
+  extra)
+
+(defun org-timestamp--extra-with-zone (extra zone)
+  "Return timestamp EXTRA preserving explicit ZONE.
+ZONE is inserted after an optional time range and before repeaters
+or warning cookies."
+  (setq extra (org-timestamp--normalize-extra extra))
+  (cond
+   ((not zone) extra)
+   ((not extra) (concat " " zone))
+   ((string-match "\\`\\(-[012][0-9]:[0-5][0-9]\\)?\\(.*\\)\\'" extra)
+    (concat (or (match-string 1 extra) "")
+            " " zone
+            (or (match-string 2 extra) "")))
+   (t (concat " " zone extra))))
+
+(defun org-insert-timestamp (time &optional with-hm inactive pre post extra zone)
   "Insert a date stamp for the date given by the internal TIME.
 See `format-time-string' for the format of TIME.
 WITH-HM means use the stamp format that includes the time of the day.
 INACTIVE means use square brackets instead of angular ones, so that the
 stamp will not contribute to the agenda.
 PRE and POST are optional strings to be inserted before and after the
-stamp.
+stamp.  ZONE, when non-nil, is the timezone used to format TIME.
 The command returns the inserted time stamp."
   (org-fold-core-ignore-modifications
     (let ((fmt (org-time-stamp-format with-hm inactive))
 	  stamp)
       (insert-before-markers-and-inherit (or pre ""))
-      (when (listp extra)
-        (setq extra (car extra))
-        (if (and (stringp extra)
-	         (string-match "\\([0-9]+\\):\\([0-9]+\\)" extra))
-	    (setq extra (format "-%02d:%02d"
-			        (string-to-number (match-string 1 extra))
-			        (string-to-number (match-string 2 extra))))
-	  (setq extra nil)))
+      (setq extra (org-timestamp--normalize-extra extra))
       (when extra
         (setq fmt (concat (substring fmt 0 -1) extra (substring fmt -1))))
-      (insert-before-markers-and-inherit (setq stamp (format-time-string fmt time)))
+      (insert-before-markers-and-inherit
+       (setq stamp (format-time-string fmt time zone)))
       (insert-before-markers-and-inherit (or post ""))
       (setq org-last-inserted-timestamp stamp))))
 
@@ -15267,7 +15309,8 @@ days in order to avoid rounding problems."
 
 (defun org-time-string-to-time (s)
   "Convert timestamp string S into internal time."
-  (org-encode-time (org-parse-time-string s)))
+  (org-timestamp--boundary-to-time
+   (org-timestamp--string-to-boundary s)))
 
 (defun org-time-string-to-seconds (s)
   "Convert a timestamp string S into a number of seconds."
@@ -15300,14 +15343,15 @@ signaled."
 	daynr
       (signal 'org-diary-sexp-no-match (list s))))
    (daynr (org-closest-date s daynr prefer))
-   (t (time-to-days
-       (condition-case errdata
-	   (org-time-string-to-time s)
-	 (error (error "Bad timestamp `%s'%s\nError was: %s"
-		       s
-		       (if (not (and buffer pos)) ""
-			 (format-message " at %d in buffer `%s'" pos buffer))
-		       (cdr errdata))))))))
+   (t
+    (condition-case errdata
+        (org-timestamp--boundary-to-absolute
+         (org-timestamp--string-to-boundary s))
+      (error (error "Bad timestamp `%s'%s\nError was: %s"
+		    s
+		    (if (not (and buffer pos)) ""
+		      (format-message " at %d in buffer `%s'" pos buffer))
+		    (cdr errdata)))))))
 
 (defun org-days-to-iso-week (days)
   "Return the ISO week number."
@@ -15667,8 +15711,8 @@ When SUPPRESS-TMP-DELAY is non-nil, suppress delays like
 	origin-cat
 	with-hm inactive
 	(dm (max (nth 1 org-time-stamp-rounding-minutes) 1))
-	extra rem
-	ts time time-original time0 fixnext clrgx)
+	extra rem zone effective-zone boundary split
+	ts ts-no-zone time time-original time0 fixnext clrgx)
     (unless timestamp? (user-error "Not at a timestamp"))
     (if (and (not what) (eq timestamp? 'bracket))
 	(org-toggle-timestamp-type)
@@ -15684,6 +15728,11 @@ When SUPPRESS-TMP-DELAY is non-nil, suppress delays like
       (setq timestamp? (or what timestamp?)
 	    inactive (= (char-after (match-beginning 0)) ?\[)
 	    ts (match-string 0))
+      (setq split (save-match-data (org-timestamp--split-zone ts))
+            ts-no-zone (car split)
+            zone (cdr split)
+            boundary (save-match-data (org-timestamp--string-to-boundary ts))
+            effective-zone (plist-get boundary :effective-zone))
       ;; FIXME: Instead of deleting everything and then inserting
       ;; later, we should make use of `replace-match', which preserves
       ;; markers.  The current implementation suffers from
@@ -15693,15 +15742,15 @@ When SUPPRESS-TMP-DELAY is non-nil, suppress delays like
       (replace-match "")
       (when (string-match
 	     "\\(\\(-[012][0-9]:[0-5][0-9]\\)?\\( +[.+]?-?[-+][0-9]+[hdwmy]\\(/[0-9]+[hdwmy]\\)?\\)*\\)[]>]"
-	     ts)
-	(setq extra (match-string 1 ts))
+	     ts-no-zone)
+	(setq extra (match-string 1 ts-no-zone))
 	(when suppress-tmp-delay
 	  (setq extra (replace-regexp-in-string " --[0-9]+[hdwmy]" "" extra))))
       (when (string-match "^.\\{10\\}.*?[0-9]+:[0-9][0-9]" ts)
 	(setq with-hm t))
-      (setq time0 (org-parse-time-string ts))
+      (setq time0 (org-timestamp--boundary-to-decoded-time boundary))
       ;; Capture the original timestamp for direction validation later
-      (setq time-original (org-encode-time time0))
+      (setq time-original (org-timestamp--boundary-to-time boundary))
       (let ((increment n))
         (if (and updown
 	         (eq timestamp? 'minute)
@@ -15718,18 +15767,16 @@ When SUPPRESS-TMP-DELAY is non-nil, suppress delays like
           ;; prefix argument.
           (setq dm 1))
         (setq time
-	      (org-encode-time
-               (if-let* ((unit
-                          (cl-case timestamp?
-                            (minute :minute)
-                            (hour :hour)
-                            (day :day)
-                            (month :month)
-                            (year :year))))
-                   (org-decoded-time-add
-                    time0
-                    (make-decoded-time unit increment))
-                 time0))))
+	      (if-let* ((unit
+                         (cl-case timestamp?
+                           (minute 'minute)
+                           (hour 'hour)
+                           (day 'day)
+                           (month 'month)
+                           (year 'year))))
+                  (org-timestamp--boundary-to-time
+                   (org-timestamp--shift-boundary boundary increment unit))
+                (org-timestamp--boundary-to-time boundary))))
       ;; Validation if we're modifying hour or minute fields
       (when (and with-hm
                  (memq timestamp? '(hour minute))
@@ -15746,7 +15793,8 @@ When SUPPRESS-TMP-DELAY is non-nil, suppress delays like
           (insert ts)
           (user-error "Cannot shift %s into the DST gap (according to current timezone '%s')"
                       ts
-                      (cadr (current-time-zone time-original)))))
+                      (or effective-zone
+                          (cadr (current-time-zone time-original))))))
       (when (and (memq timestamp? '(hour minute))
 		 extra
 		 (string-match "-\\([012][0-9]\\):\\([0-5][0-9]\\)" extra))
@@ -15764,22 +15812,27 @@ When SUPPRESS-TMP-DELAY is non-nil, suppress delays like
 	(setq extra (org-modify-ts-extra extra timestamp? n dm)))
       (when (eq what 'calendar)
 	(let ((cal-date (org-get-date-from-calendar)))
-          (setq time (org-encode-time
-                      (decoded-time-set-defaults
-                       (make-decoded-time
-                        :month (calendar-extract-month cal-date)
-                        :day   (calendar-extract-day   cal-date)
-                        :year  (calendar-extract-year  cal-date)
-                        :second (decoded-time-second time0)
-                        :minute (decoded-time-minute time0)
-                        :hour   (decoded-time-hour   time0)))))))
+          (setq time
+                (org-timestamp--encode-decoded-time
+                 (decoded-time-set-defaults
+                  (make-decoded-time
+                   :month (calendar-extract-month cal-date)
+                   :day   (calendar-extract-day   cal-date)
+                   :year  (calendar-extract-year  cal-date)
+                   :second (decoded-time-second time0)
+                   :minute (decoded-time-minute time0)
+                   :hour   (decoded-time-hour   time0)))
+                 effective-zone))))
       ;; Insert the new timestamp, and ensure point stays in the same
       ;; category as before (i.e. not after the last position in that
       ;; category).
       (let ((pos (point)))
 	;; Stay before inserted string. `save-excursion' is of no use.
 	(setq org-last-changed-timestamp
-	      (org-insert-timestamp time with-hm inactive nil nil extra))
+	      (org-insert-timestamp
+               time with-hm inactive nil nil
+               (org-timestamp--extra-with-zone extra (and with-hm zone))
+               (and with-hm effective-zone)))
 	(goto-char pos))
       (save-match-data
 	(looking-at org-ts-regexp3)
@@ -15842,7 +15895,10 @@ When SUPPRESS-TMP-DELAY is non-nil, suppress delays like
       (when (and org-calendar-follow-timestamp-change
 		 (get-buffer-window calendar-buffer t)
 		 (memq timestamp? '(day month year)))
-	(org-recenter-calendar (time-to-days time))))))
+	(org-recenter-calendar
+         (calendar-absolute-from-gregorian
+          (org-timestamp--boundary-calendar-date
+           (plist-put (copy-sequence boundary) :instant time))))))))
 
 (defun org-modify-ts-extra (ts-string pos nincrements increment-step)
   "Change the lead-time/repeat fields at POS in timestamp string TS-STRING.
@@ -20719,14 +20775,8 @@ return an active timestamp."
   "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))))
+  (org-timestamp--boundary-to-time
+   (org-timestamp--normalize-boundary timestamp end)))
 
 (defun org-timestamp-has-time-p (timestamp)
   "Non-nil when TIMESTAMP has a time specified."
@@ -20743,8 +20793,10 @@ time-range, if possible.
 
 When optional argument UTC is non-nil, time is expressed as
 Universal Time."
-  (format-time-string format (org-timestamp-to-time timestamp end)
-		      (and utc t)))
+  (org-timestamp--format-boundary
+   (org-timestamp--normalize-boundary timestamp end)
+   format
+   utc))
 
 (defun org-timestamp-split-range (timestamp &optional end)
   "Extract a TIMESTAMP object from a date or time range.
@@ -20766,7 +20818,8 @@ Return a new timestamp object."
 			 (:hour-start . :hour-end)
 			 (:day-start . :day-end)
 			 (:month-start . :month-end)
-			 (:year-start . :year-end))))
+			 (:year-start . :year-end)
+                         (:zone-start . :zone-end))))
 	  (dolist (p-cell p-alist)
 	    (org-element-put-property
 	     split-ts
diff --git a/lisp/ox-icalendar.el b/lisp/ox-icalendar.el
index 272e3cf5d..4fe241004 100644
--- a/lisp/ox-icalendar.el
+++ b/lisp/ox-icalendar.el
@@ -37,6 +37,7 @@
 
 (require 'cl-lib)
 (require 'org-agenda)
+(require 'org-timestamp)
 (require 'ox-ascii)
 (declare-function org-bbdb-anniv-export-ical "ol-bbdb" nil)
 
@@ -492,7 +493,8 @@ added to the timestamp.  It can be the string \"UTC\", to use UTC
 time, or a string in the IANA TZ database
 format (e.g. \"Europe/London\").  In either case, the value of
 `org-icalendar-date-time-format' will be ignored."
-  (let* ((year-start (org-element-property :year-start timestamp))
+  (let* ((boundary (org-timestamp--normalize-boundary timestamp end))
+         (year-start (org-element-property :year-start timestamp))
 	 (year-end (org-element-property :year-end timestamp))
 	 (month-start (org-element-property :month-start timestamp))
 	 (month-end (org-element-property :month-end timestamp))
@@ -502,7 +504,10 @@ format (e.g. \"Europe/London\").  In either case, the value of
 	 (hour-end (org-element-property :hour-end timestamp))
 	 (minute-start (org-element-property :minute-start timestamp))
 	 (minute-end (org-element-property :minute-end timestamp))
-	 (with-time-p minute-start)
+	 (with-time-p (eq (plist-get boundary :granularity) 'minute))
+         (timestamp-zone (and with-time-p (plist-get boundary :zone)))
+         (effective-zone (and with-time-p (plist-get boundary :effective-zone)))
+         (tz (or timestamp-zone tz))
 	 (equal-bounds-p
 	  (equal (list year-start month-start day-start hour-start minute-start)
 		 (list year-end month-end day-end hour-end minute-end)))
@@ -534,12 +539,11 @@ format (e.g. \"Europe/London\").  In either case, the value of
 					 t)))
       ;; Convert timestamp into internal time in order to use
       ;; `format-time-string' and fix any mistake (i.e. MI >= 60).
-      (org-encode-time 0 mi h d m y)
-      (and (or (string-equal tz "UTC")
-	       (and (null tz)
-		    with-time-p
-		    (org-icalendar-use-UTC-date-time-p)))
-	   t)))))
+      (org-encode-time 0 mi h d m y nil -1 effective-zone)
+      (cond
+       ((string-equal tz "UTC") t)
+       ((stringp effective-zone) effective-zone)
+       ((and (null tz) with-time-p (org-icalendar-use-UTC-date-time-p)) t))))))
 
 (defun org-icalendar-dtstamp ()
   "Return DTSTAMP property, as a string."
diff --git a/testing/lisp/test-org-agenda.el b/testing/lisp/test-org-agenda.el
index 9af012b7d..82ffb56f8 100644
--- a/testing/lisp/test-org-agenda.el
+++ b/testing/lisp/test-org-agenda.el
@@ -376,6 +376,96 @@ See https://list.orgmode.org/[email protected]";
     (should (= 3 (count-lines (point-min) (point-max)))))
   (org-test-agenda--kill-all-agendas))
 
+(ert-deftest test-org-agenda/timestamp-explicit-zone-placement ()
+  "Place explicit-zoned timestamps by their agenda-local date."
+  (org-test-with-timezone "America/New_York"
+    (org-test-with-temp-text-in-file
+        "* TODO Zoned
+SCHEDULED: <2026-07-20 Mon 00:30 Europe/Warsaw>"
+      (org-test-at-time "2026-07-18 00:00"
+        (let ((july-19 (org-agenda-get-day-entries
+                        buffer-file-name '(7 19 2026) :scheduled))
+              (july-20 (org-agenda-get-day-entries
+                        buffer-file-name '(7 20 2026) :scheduled)))
+          (should (seq-some
+                   (lambda (item) (string-match-p "Zoned" item))
+                   july-19))
+          (should-not (seq-some
+                       (lambda (item) (string-match-p "Zoned" item))
+                       july-20)))))))
+
+(ert-deftest test-org-agenda/timestamp-explicit-zone-display-time ()
+  "Display explicit-zoned timestamp times in the agenda timezone."
+  (org-test-with-timezone "Europe/Warsaw"
+    (let ((org-agenda-span 'day)
+          (org-agenda-prefix-format '((agenda . " %t "))))
+      (org-test-agenda-with-agenda
+          "* TODO Zoned
+SCHEDULED: <2026-07-21 Tue 19:17 America/New_York>"
+        (org-agenda-list nil "<2026-07-22 Wed>")
+        (with-current-buffer org-agenda-buffer-name
+          (should (string-match-p
+                   " 1:17[. ]+TODO Zoned"
+                   (buffer-substring-no-properties
+                    (point-min) (point-max)))))))))
+
+(ert-deftest test-org-agenda/timestamp-explicit-zone-display-next-day ()
+  "Place and display explicit-zoned timestamps after local midnight."
+  (org-test-with-timezone "Europe/Warsaw"
+    (let ((org-agenda-span 'day)
+          (org-agenda-prefix-format '((agenda . " %t "))))
+      (org-test-agenda-with-agenda
+          "* TODO Zoned
+SCHEDULED: <2026-07-22 Wed 19:17 America/New_York>"
+        (org-agenda-list nil "<2026-07-23 Thu>")
+        (with-current-buffer org-agenda-buffer-name
+          (should (string-match-p
+                   " 1:17[. ]+TODO Zoned"
+                   (buffer-substring-no-properties
+                    (point-min) (point-max)))))))))
+
+(ert-deftest test-org-agenda/timestamp-explicit-zone-repeater-placement ()
+  "Place explicit-zoned repeaters by their agenda-local date."
+  (org-test-with-timezone "Europe/Warsaw"
+    (let ((org-agenda-span 'day)
+          (org-agenda-prefix-format '((agenda . " %t "))))
+      (org-test-agenda-with-agenda
+          "* TODO Zoned
+SCHEDULED: <2026-07-22 Wed 19:17 America/New_York +1d>"
+        (org-agenda-list nil "<2026-07-22 Wed>")
+        (with-current-buffer org-agenda-buffer-name
+          (should-not (string-match-p
+                       "TODO Zoned"
+                       (buffer-substring-no-properties
+                        (point-min) (point-max)))))
+        (org-agenda-list nil "<2026-07-23 Thu>")
+        (with-current-buffer org-agenda-buffer-name
+          (should (string-match-p
+                   " 1:17[. ]+TODO Zoned"
+                   (buffer-substring-no-properties
+                    (point-min) (point-max)))))
+        (org-agenda-list nil "<2026-07-24 Fri>")
+        (with-current-buffer org-agenda-buffer-name
+          (should (string-match-p
+                   " 1:17[. ]+TODO Zoned"
+                   (buffer-substring-no-properties
+                    (point-min) (point-max)))))))))
+
+(ert-deftest test-org-agenda/timestamp-explicit-zone-active-repeater-placement ()
+  "Place active explicit-zoned repeaters by their agenda-local date."
+  (org-test-with-timezone "Europe/Warsaw"
+    (let ((org-agenda-span 'day)
+          (org-agenda-prefix-format '((agenda . " %t "))))
+      (org-test-agenda-with-agenda
+          "* TODO Zoned
+<2026-07-22 Wed 19:17 America/New_York +1d>"
+        (org-agenda-list nil "<2026-07-23 Thu>")
+        (with-current-buffer org-agenda-buffer-name
+          (should (string-match-p
+                   " 1:17[. ]+TODO Zoned"
+                   (buffer-substring-no-properties
+                    (point-min) (point-max)))))))))
+
 (ert-deftest test-org-agenda/set-priority ()
   "One informative line in the agenda.  Check that org-agenda-priority updates the agenda."
   (cl-assert (not org-agenda-sticky) nil "precondition violation")
diff --git a/testing/lisp/test-org-element.el b/testing/lisp/test-org-element.el
index cc058fff9..bc9d4310b 100644
--- a/testing/lisp/test-org-element.el
+++ b/testing/lisp/test-org-element.el
@@ -3250,6 +3250,18 @@ Outside list"
 		    (org-element-property :day-start object)
 		    (org-element-property :hour-start object)
 		    (org-element-property :minute-start object))))))
+  ;; Explicit IANA timezone.
+  (should
+   (equal '("Europe/Warsaw" nil)
+          (org-test-with-temp-text "<2026-07-19 Sun 12:00 Europe/Warsaw>"
+            (let ((object (org-element-context)))
+              (list (org-element-property :zone-start object)
+                    (org-element-property :effective-zone-start object))))))
+  ;; Date-only explicit IANA timezone.
+  (should
+   (equal "Europe/Warsaw"
+          (org-test-with-temp-text "<2026-07-19 Sun Europe/Warsaw>"
+            (org-element-property :zone-start (org-element-context)))))
   ;; Inactive timestamp.
   (should
    (org-test-with-temp-text "[2012-03-29 Thu 16:40]"
@@ -3270,6 +3282,12 @@ Outside list"
    (eq 'active-range
        (org-test-with-temp-text "<2012-03-29 Thu 7:30-16:40>"
 	 (org-element-property :type (org-element-context)))))
+  (should
+   (equal '("Europe/Warsaw" "Europe/Warsaw")
+          (org-test-with-temp-text "<2026-07-19 Sun 10:00-11:00 Europe/Warsaw>"
+            (let ((object (org-element-context)))
+              (list (org-element-property :zone-start object)
+                    (org-element-property :zone-end object))))))
   ;; Date range.
   (should
    (org-test-with-temp-text "[2012-03-29 Thu 16:40]--[2012-03-29 Thu 16:41]"
@@ -3279,6 +3297,20 @@ Outside list"
      (let ((timestamp (org-element-context)))
        (or (org-element-property :hour-end timestamp)
 	   (org-element-property :minute-end timestamp)))))
+  (should
+   (equal '("Europe/Warsaw" "Europe/Berlin")
+          (org-test-with-temp-text
+              "<2026-07-19 Sun 22:00 Europe/Warsaw>--<2026-07-20 Mon 01:00 Europe/Berlin>"
+            (let ((object (org-element-context)))
+              (list (org-element-property :zone-start object)
+                    (org-element-property :zone-end object))))))
+  (should
+   (equal '("Europe/Warsaw" nil)
+          (org-test-with-temp-text
+              "<2026-07-19 Sun 22:00 Europe/Warsaw>--<2026-07-20 Mon 01:00>"
+            (let ((object (org-element-context)))
+              (list (org-element-property :zone-start object)
+                    (org-element-property :zone-end object))))))
   ;; With repeater, repeater deadline, warning delay and combinations.
   (should
    (eq 'catch-up
@@ -3301,6 +3333,13 @@ Outside list"
 	    (let ((ts (org-element-context)))
 	      (list (org-element-property :repeater-type ts)
 		    (org-element-property :warning-type ts))))))
+  (should
+   (equal '("Europe/Warsaw" cumulate all)
+          (org-test-with-temp-text "<2026-07-19 Sun 12:00 Europe/Warsaw +1w -2d>"
+            (let ((ts (org-element-context)))
+              (list (org-element-property :zone-start ts)
+                    (org-element-property :repeater-type ts)
+                    (org-element-property :warning-type ts))))))
   (should
    (equal '(cumulate all 2 year)
           (org-test-with-temp-text "<2012-03-29 Thu +1y/2y -1y>"
@@ -4016,10 +4055,25 @@ DEADLINE: <2012-03-29 thu.> SCHEDULED: <2012-03-29 thu.> CLOSED: [2012-03-29 thu
        (:type active :year-start 2012 :month-start 3 :day-start 29
 	      :hour-start 16 :minute-start 40 :year-end 2012 :month-end 3
 	      :day-end 29 :hour-end 16 :minute-end 40)) nil)))
+  (should
+   (string-match
+    "<2026-07-19 .* 12:00 Europe/Warsaw>"
+    (org-test-parse-and-interpret
+     "<2026-07-19 Sun 12:00 Europe/Warsaw>")))
+  (should
+   (string-match
+    "<2026-07-19 .* Europe/Warsaw>"
+    (org-test-parse-and-interpret
+     "<2026-07-19 Sun Europe/Warsaw>")))
   ;; Inactive.
   (should
    (string-match "\\[2012-03-29 .* 16:40\\]"
 		 (org-test-parse-and-interpret "[2012-03-29 thu. 16:40]")))
+  (should
+   (string-match
+    "\\[2026-07-19 .* 12:00 Europe/Warsaw\\]"
+    (org-test-parse-and-interpret
+     "[2026-07-19 Sun 12:00 Europe/Warsaw]")))
   (should
    (string-match
     "\\[2012-03-29 .* 16:40\\]"
@@ -4092,6 +4146,21 @@ DEADLINE: <2012-03-29 thu.> SCHEDULED: <2012-03-29 thu.> CLOSED: [2012-03-29 thu
    (string-match "<2012-03-29 .* 16:40-16:41>"
 		 (org-test-parse-and-interpret
 		  "<2012-03-29 thu. 16:40-16:41>")))
+  (should
+   (string-match
+    "<2026-07-19 .* 10:00-11:00 Europe/Warsaw>"
+    (org-test-parse-and-interpret
+     "<2026-07-19 Sun 10:00-11:00 Europe/Warsaw>")))
+  (should
+   (string-match
+    "<2026-07-19 .* 22:00 Europe/Warsaw>--<2026-07-20 .* 01:00 Europe/Berlin>"
+    (org-test-parse-and-interpret
+     "<2026-07-19 Sun 22:00 Europe/Warsaw>--<2026-07-20 Mon 01:00 Europe/Berlin>")))
+  (should
+   (string-match
+    "<2026-07-19 .* 12:00 Europe/Warsaw \\+1w -2d>"
+    (org-test-parse-and-interpret
+     "<2026-07-19 Sun 12:00 Europe/Warsaw +1w -2d>")))
   ;; Diary.
   (should (equal (org-test-parse-and-interpret "<%%(diary-float t 4 2)>")
 		 "<%%(diary-float t 4 2)>\n"))
diff --git a/testing/lisp/test-org.el b/testing/lisp/test-org.el
index 9e9be9ebc..d8c01f99b 100644
--- a/testing/lisp/test-org.el
+++ b/testing/lisp/test-org.el
@@ -9673,6 +9673,63 @@ Behavior can be modified by setting `org-log-into-drawer', by keywords in
       (test-time-stamp-rounding "<2026-03-14 12:00>" (1+ i) -1
                                 (concat "11:5" (number-to-string (- 9 i)))))))
 
+(ert-deftest test-org/timestamp-change-preserves-zone-policy ()
+  "Changing a timestamp preserves explicit or floating zone policy."
+  (cl-flet ((change-timestamp (text expected)
+              (org-test-with-temp-text text
+                (org-timestamp-change 1 'day)
+                (should (equal expected (buffer-string))))))
+    (org-test-with-timezone "Europe/Warsaw"
+      (change-timestamp
+       "<2026-07-20 Mon<point> 23:00 Europe/Warsaw>"
+       "<2026-07-21 Tue 23:00 Europe/Warsaw>")
+      (change-timestamp
+       "<2026-07-21 Tue<point> 19:17 America/New_York>"
+       "<2026-07-22 Wed 19:17 America/New_York>")
+      (change-timestamp
+       "<2026-07-20 Mon<point> 23:00>"
+       "<2026-07-21 Tue 23:00>"))))
+
+(ert-deftest test-org/timestamp-replace-preserves-zone-policy ()
+  "Replacing timestamp date preserves explicit or floating zone policy."
+  (cl-flet ((replace-date (text expected)
+              (org-test-with-temp-text text
+                (goto-char 2)
+                (cl-letf (((symbol-function 'org-read-date)
+                           (lambda (&rest _)
+                             (setq org-time-was-given t)
+                             (org-encode-time 0 0 23 21 7 2026))))
+                  (org-timestamp nil))
+                (should (equal expected (buffer-string))))))
+    (org-test-with-timezone "Europe/Warsaw"
+      (replace-date "<2026-07-20 Mon 23:00>"
+                    "<2026-07-21 Tue 23:00>")
+      (replace-date "<2026-07-20 Mon 23:00 Europe/Warsaw>"
+                    "<2026-07-21 Tue 23:00 Europe/Warsaw>"))))
+
+(ert-deftest test-org/timestamp-replace-uses-zone-local-default ()
+  "Replacing a zoned timestamp uses its zone-local date as default."
+  (org-test-with-timezone "Europe/Warsaw"
+    (org-test-with-temp-text "<2026-07-21 Tue 19:17 America/New_York>"
+      (goto-char 2)
+      (cl-letf (((symbol-function 'org-read-date)
+                 (lambda (&rest args)
+                   (let* ((default-time (nth 4 args))
+                          (decoded
+                           (decode-time default-time org-read-date-effective-zone)))
+                     (should (equal org-read-date-effective-zone
+                                    "America/New_York"))
+                     (should (= 2026 (decoded-time-year decoded)))
+                     (should (= 7 (decoded-time-month decoded)))
+                     (should (= 21 (decoded-time-day decoded)))
+                     (setq org-time-was-given t)
+                     (org-encode-time
+                      0 17 19 22 6 2027 nil -1
+                      org-read-date-effective-zone)))))
+        (org-timestamp nil))
+      (should (equal "<2027-06-22 Tue 19:17 America/New_York>"
+                     (buffer-string))))))
+
 (ert-deftest test-org/org-timestamp-change/bad ()
   "Promises that are currently broken in `org-timestamp-change'."
   :expected-result :failed
@@ -9866,6 +9923,11 @@ Behavior can be modified by setting `org-log-into-drawer', by keywords in
     "2012-03-29 16:40"
     (org-test-with-temp-text "<2012-03-29 Thu 16:40>"
       (org-format-timestamp (org-element-context) "%Y-%m-%d %R"))))
+  (should
+   (equal
+    "2026-07-19 12:00"
+    (org-test-with-temp-text "<2026-07-19 Sun 12:00 Europe/Warsaw>"
+      (org-format-timestamp (org-element-context) "%Y-%m-%d %R"))))
   ;; Range end.
   (should
    (equal
@@ -9984,6 +10046,11 @@ Behavior can be modified by setting `org-log-into-drawer', by keywords in
    (string-match-p "[2014-03-04 .+]"
 		   (org-element-interpret-data
 		    (org-timestamp-from-string "[2014-03-04 Tue]")))))
+  (should
+   (string-match-p "<2026-07-19 .+ 12:00 Europe/Warsaw>"
+		   (org-element-interpret-data
+		    (org-timestamp-from-string
+		     "<2026-07-19 Sun 12:00 Europe/Warsaw>"))))
 
 (ert-deftest test-org/timestamp-from-time ()
   "Test `org-timestamp-from-time' specifications."
@@ -10013,6 +10080,37 @@ Behavior can be modified by setting `org-log-into-drawer', by keywords in
       (org-time-string-to-time "<2012-03-29 Thu 16:40>")
       nil t)))))
 
+(ert-deftest test-org/timestamp-normalize-boundary ()
+  "Test normalized timestamp boundary helpers."
+  (let* ((timestamp
+          (org-timestamp-from-string
+           "<2026-07-19 Sun 12:00 Europe/Warsaw>"))
+         (boundary (org-timestamp--normalize-boundary timestamp)))
+    (should (equal "Europe/Warsaw" (plist-get boundary :zone)))
+    (should (equal "Europe/Warsaw" (plist-get boundary :effective-zone)))
+    (should (eq 'minute (plist-get boundary :granularity)))
+    (should
+     (equal
+      (org-time-string-to-time "<2026-07-19 Sun 12:00 Europe/Warsaw>")
+      (plist-get boundary :instant))))
+  (let* ((timestamp
+          (org-timestamp-from-string
+           "<2026-07-19 Sun 10:00-11:00 Europe/Warsaw>"))
+         (boundary (org-timestamp--normalize-boundary timestamp t)))
+    (should (equal "Europe/Warsaw" (plist-get boundary :effective-zone)))
+    (should (eq 'minute (plist-get boundary :granularity)))
+    (should
+     (equal "2026-07-19 09:00"
+            (format-time-string "%Y-%m-%d %H:%M"
+                                (plist-get boundary :instant)
+                                t))))
+  (let ((boundary
+         (org-timestamp--normalize-boundary
+          (org-timestamp-from-string "<2026-07-19 Sun>"))))
+    (should-not (plist-get boundary :zone))
+    (should-not (plist-get boundary :effective-zone))
+    (should (eq 'date (plist-get boundary :granularity)))))
+
 (ert-deftest test-org/timestamp-to-time ()
   "Test `org-timestamp-to-time' specifications."
   (should
@@ -10033,6 +10131,23 @@ Behavior can be modified by setting `org-log-into-drawer', by keywords in
 	   "%Y-%m-%d %H:%M"
 	   (org-timestamp-to-time
 	    (org-timestamp-from-string "<2012-03-29 Thu 08:30-16:40>")))))
+  (should
+   (equal "2026-07-19 10:00"
+          (format-time-string
+           "%Y-%m-%d %H:%M"
+           (org-timestamp-to-time
+            (org-timestamp-from-string
+             "<2026-07-19 Sun 12:00 Europe/Warsaw>"))
+           t)))
+  (should
+   (equal
+    (org-time-string-to-time "<2026-07-19 Sun 12:00 Europe/Warsaw>")
+    (org-time-string-to-time "<2026-07-19 Sun 11:00 Europe/London>")))
+  (org-test-with-timezone "America/New_York"
+    (should
+     (= (calendar-absolute-from-gregorian '(7 20 2026))
+        (org-time-string-to-absolute
+         "<2026-07-20 Mon 00:30 Europe/Warsaw>"))))
   (should
    (equal "2012-03-29"
 	  (format-time-string
@@ -10054,6 +10169,15 @@ Behavior can be modified by setting `org-log-into-drawer', by keywords in
 	   (org-timestamp-to-time
 	    (org-timestamp-from-string "<2012-03-29 Thu 08:30-16:40>")
 	    t))))
+  (should
+   (equal "2026-07-19 09:00"
+          (format-time-string
+           "%Y-%m-%d %H:%M"
+           (org-timestamp-to-time
+            (org-timestamp-from-string
+             "<2026-07-19 Sun 10:00-11:00 Europe/Warsaw>")
+            t)
+           t)))
   (should
    (equal "2014-03-04"
 	  (format-time-string
diff --git a/testing/lisp/test-ox-icalendar.el b/testing/lisp/test-ox-icalendar.el
index 28d148f34..b7124dacc 100644
--- a/testing/lisp/test-ox-icalendar.el
+++ b/testing/lisp/test-ox-icalendar.el
@@ -116,6 +116,21 @@ DEADLINE: <2023-05-02 Tue> SCHEDULED: <2023-03-26 Sun 15:00 +3d>"
             (should (re-search-forward "RRULE:FREQ=DAILY;INTERVAL=3;UNTIL=2023050.T..0000Z"))))
       (when (file-exists-p tmp-ics) (delete-file tmp-ics)))))
 
+(ert-deftest test-ox-icalendar/timestamp-explicit-zone ()
+  "Test iCalendar export of timestamp with explicit IANA timezone."
+  (let ((tmp-ics (org-test-with-temp-text-in-file
+                  "* Test event
+:PROPERTIES:
+:ID:       f3e0ddcb-226d-4d56-ae47-87fa4b39f293
+:END:
+<2026-07-19 Sun 12:00 Europe/Warsaw>"
+                  (expand-file-name (org-icalendar-export-to-ics)))))
+    (unwind-protect
+        (with-temp-buffer
+          (insert-file-contents tmp-ics)
+          (should (search-forward "DTSTART;TZID=Europe/Warsaw:20260719T120000")))
+      (when (file-exists-p tmp-ics) (delete-file tmp-ics)))))
+
 (ert-deftest test-ox-icalendar/warn-unsupported-repeater ()
   "Test warning is emitted for unsupported repeater type."
   (let ((org-icalendar-include-todo 'all))
-- 
2.54.0

Reply via email to