From 3ae815960a62af2fbfe4b51e3ac2a76eadf1e3c9 Mon Sep 17 00:00:00 2001
From: Alexandre Felipe <o.alexandre.felipe@gmail.com>
Date: Sun, 30 Aug 2026 10:51:07 +0100
Subject: [PATCH-v1] date_trunc redotz for hours and minutes

When truncating a timestamp, one can cross timezone transitions,
unless the local time after the transition is at time hh:00:00
or the offset change is a multiple of 3600, date_trunc requires
handle the timezone transitions.

trunc_timestamptz_internal already handled this when truncating
dates (day or lager units), mediated by a flag redotz. This commit
simply sets redotz for the time part (minute or larger units).

The issue can be observed with t = '1916-07-27 22:26:08+00'
and timezone 'Europe/Athens' prior to this commit.

This happens because the input timestamptz is
1916-07-28 00:26:08 at Europe/Athens, just after a transition,
subtracting 26 min 8 sec, causes the timestamp to be interpreted
at a different offset.

---
 src/backend/utils/adt/timestamp.c         |  6 +++++-
 src/test/regress/expected/timestamptz.out | 22 ++++++++++++++++++++++
 src/test/regress/sql/timestamptz.sql      |  9 +++++++++
 3 files changed, 36 insertions(+), 1 deletion(-)

diff --git a/src/backend/utils/adt/timestamp.c b/src/backend/utils/adt/timestamp.c
index 9c17ba2f905..4eb0c6876f3 100644
--- a/src/backend/utils/adt/timestamp.c
+++ b/src/backend/utils/adt/timestamp.c
@@ -5055,13 +5055,17 @@ timestamptz_trunc_internal(text *units, TimestampTz timestamp, pg_tz *tzp)
 				pg_fallthrough;
 			case DTK_DAY:
 				tm->tm_hour = 0;
-				redotz = true;	/* for all cases >= DAY */
 				pg_fallthrough;
 			case DTK_HOUR:
 				tm->tm_min = 0;
 				pg_fallthrough;
 			case DTK_MINUTE:
 				tm->tm_sec = 0;
+				/*
+				 * Truncating anything larger than a second it might
+				 * cross a time transition.
+				 */
+				redotz = true;
 				pg_fallthrough;
 			case DTK_SECOND:
 				fsec = 0;
diff --git a/src/test/regress/expected/timestamptz.out b/src/test/regress/expected/timestamptz.out
index 5dc8a621f6c..8d2e3e10a94 100644
--- a/src/test/regress/expected/timestamptz.out
+++ b/src/test/regress/expected/timestamptz.out
@@ -800,6 +800,28 @@ SELECT date_trunc( 'week', timestamp with time zone 'infinity', 'GMT') AS inf_zo
 
 SELECT date_trunc('ago', timestamp with time zone 'infinity', 'GMT') AS invalid_zone_trunc;
 ERROR:  unit "ago" not recognized for type timestamp with time zone
+SET timezone to 'UTC';
+SELECT unit, side,
+       timezone(z, date_trunc(unit, t::timestamptz, z)) as "local",
+       timezone('UTC', date_trunc(unit, t::timestamptz, z)) as "UTC"
+FROM (VALUES ('before', '1916-07-27 22:26:07.987654+00', 'Europe/Athens'),
+             ('after', '1916-07-27 22:26:08.123456+00', 'Europe/Athens')) t1(side, t,z),
+     (VALUES ('month'), ('day'), ('hour'), ('minute'), ('sec')) t2(unit);
+  unit  |  side  |          local           |           UTC            
+--------+--------+--------------------------+--------------------------
+ month  | before | Sat Jul 01 00:00:00 1916 | Fri Jun 30 22:25:08 1916
+ month  | after  | Sat Jul 01 00:00:00 1916 | Fri Jun 30 22:25:08 1916
+ day    | before | Fri Jul 28 00:00:00 1916 | Thu Jul 27 22:25:08 1916
+ day    | after  | Fri Jul 28 00:00:00 1916 | Thu Jul 27 22:25:08 1916
+ hour   | before | Fri Jul 28 00:00:00 1916 | Thu Jul 27 22:25:08 1916
+ hour   | after  | Fri Jul 28 00:00:00 1916 | Thu Jul 27 22:25:08 1916
+ minute | before | Fri Jul 28 00:00:00 1916 | Thu Jul 27 22:25:08 1916
+ minute | after  | Fri Jul 28 00:51:08 1916 | Thu Jul 27 22:51:08 1916
+ sec    | before | Fri Jul 28 00:00:59 1916 | Thu Jul 27 22:26:07 1916
+ sec    | after  | Fri Jul 28 00:26:08 1916 | Thu Jul 27 22:26:08 1916
+(10 rows)
+
+RESET timezone;
 -- verify date_bin behaves the same as date_trunc for relevant intervals
 SELECT
   str,
diff --git a/src/test/regress/sql/timestamptz.sql b/src/test/regress/sql/timestamptz.sql
index 6ace851d169..6d308644680 100644
--- a/src/test/regress/sql/timestamptz.sql
+++ b/src/test/regress/sql/timestamptz.sql
@@ -229,6 +229,15 @@ SELECT date_trunc('timezone', timestamp with time zone 'infinity', 'GMT') AS not
 SELECT date_trunc( 'week', timestamp with time zone 'infinity', 'GMT') AS inf_zone_trunc;
 SELECT date_trunc('ago', timestamp with time zone 'infinity', 'GMT') AS invalid_zone_trunc;
 
+SET timezone to 'UTC';
+SELECT unit, side,
+       timezone(z, date_trunc(unit, t::timestamptz, z)) as "local",
+       timezone('UTC', date_trunc(unit, t::timestamptz, z)) as "UTC"
+FROM (VALUES ('before', '1916-07-27 22:26:07.987654+00', 'Europe/Athens'),
+             ('after', '1916-07-27 22:26:08.123456+00', 'Europe/Athens')) t1(side, t,z),
+     (VALUES ('month'), ('day'), ('hour'), ('minute'), ('sec')) t2(unit);
+RESET timezone;
+
 -- verify date_bin behaves the same as date_trunc for relevant intervals
 SELECT
   str,
-- 
2.53.0

