From 3e293e6ac17e9ebd08aa64330022bbc3aa6ad3d1 Mon Sep 17 00:00:00 2001 From: mottopanikeiku <176798723+mottopanikeiku@users.noreply.github.com> Date: Tue, 6 Oct 2026 03:34:48 -0700 Subject: [PATCH] Fix timeline bar lengths across daylight saving changes I calculate timeline bar lengths in the same local wall-clock coordinates used by date axes, so timezone-aware endpoints remain aligned across daylight saving changes. I leave the original start column unchanged and cover spring, autumn, UTC, and naive timestamps across dataframe backends. Fixes plotly/plotly.py#4611 Signed-off-by: mottopanikeiku <176798723+mottopanikeiku@users.noreply.github.com> --- CHANGELOG.md | 1 + plotly/express/_core.py | 13 +++++++--- .../test_px/test_px_functions.py | 26 +++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea837e3d0b..49d1f8f139 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## Unreleased ### Fixed +- Fix `px.timeline` bars ending an hour early or late when timezone-aware dates span a daylight saving time change [[#4611](https://github.com/plotly/plotly.py/issues/4611)] - Fix concurrent first access to lazily initialized graph object properties, which could raise `ValueError("Invalid value")` [[#5691](https://github.com/plotly/plotly.py/pull/5691)], with thanks to @hb1915 for the contribution! - Fix `mpl_to_plotly` crashing on touching bars (such as `plt.hist`) due to floating-point noise producing negative `bargap` values by clamping `bargap` to `[0, 1]` [[#5696](https://github.com/plotly/plotly.py/pull/5696)], with thanks to @robertoffmoura for the contribution! - Fix `px.sunburst`, `px.treemap` and `px.icicle` listing sectors in a different order on every run when `path` is used with a Polars DataFrame; sectors now follow their order of first appearance for all dataframe backends [[#5766](https://github.com/plotly/plotly.py/pull/5766)], with thanks to @Irahan2 for the contribution! diff --git a/plotly/express/_core.py b/plotly/express/_core.py index e99bf40b87..797ffea5f8 100644 --- a/plotly/express/_core.py +++ b/plotly/express/_core.py @@ -2193,11 +2193,18 @@ def process_dataframe_timeline(args): "Both x_start and x_end must refer to data convertible to datetimes." ) from exc + # Match the local times displayed on date axes when calculating bar lengths. + schema = df.schema + x_start, x_end = [ + nw.col(col).dt.replace_time_zone(None) + if schema[col] == nw.Datetime and schema[col].time_zone is not None + else nw.col(col) + for col in (args["x_start"], args["x_end"]) + ] + # note that we are not adding any columns to the data frame here, so no risk of overwrite args["data_frame"] = df.with_columns( - (nw.col(args["x_end"]) - nw.col(args["x_start"])) - .dt.total_milliseconds() - .alias(args["x_end"]) + (x_end - x_start).dt.total_milliseconds().alias(args["x_end"]) ) args["x"] = args["x_end"] args["base"] = args["x_start"] diff --git a/tests/test_optional/test_px/test_px_functions.py b/tests/test_optional/test_px/test_px_functions.py index 84a451733b..2ce41c4550 100644 --- a/tests/test_optional/test_px/test_px_functions.py +++ b/tests/test_optional/test_px/test_px_functions.py @@ -680,6 +680,32 @@ def test_timeline_cols_already_temporal(constructor, datetime_columns): assert fig.layout.xaxis.title.text is None +@pytest.mark.parametrize("time_zone", [None, "UTC", "US/Pacific"]) +def test_timeline_daylight_saving(constructor, time_zone): + # Regression for https://github.com/plotly/plotly.py/issues/4611 + starts = ["2024-03-09", "2024-03-10", "2024-03-11", "2023-11-05"] + finishes = ["2024-03-10", "2024-03-11", "2024-03-12", "2023-11-06"] + df = nw.from_native( + constructor({"Start": starts, "Finish": finishes, "Task": ["Job A"] * 4}) + ).with_columns( + nw.col("Start", "Finish") + .str.to_datetime(format="%Y-%m-%d") + .dt.replace_time_zone(time_zone) + ) + + fig = px.timeline(df.to_native(), x_start="Start", x_end="Finish", y="Task") + + # Date axes use local wall-clock coordinates: consecutive midnights are + # 24 hours apart even when the elapsed time is 23 or 25 hours. + assert_array_equal(fig.data[0].x, [24 * 60 * 60 * 1000] * 4) + base = np.asarray(fig.data[0].base, dtype="datetime64[ms]") + assert_array_equal(base, np.array(starts, dtype="datetime64[ms]")) + assert_array_equal( + base + fig.data[0].x.astype("timedelta64[ms]"), + np.array(finishes, dtype="datetime64[ms]"), + ) + + def test_empty_histogram(): """Empty px.histogram() should not raise, matching scatter/bar/pie behavior.