diff --git a/CHANGELOG.md b/CHANGELOG.md index 42622e66bf..28d14dbcbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - Fix `mpl_to_plotly` crashing on figures with fully transparent or alpha-carrying colors, such as boxplot outlier markers (`facecolor="none"`), rgba line colors, and `color="none"` text and axis labels; all matplotlib colors are now exported through a single helper that maps `"none"` to transparent rgba and keeps or overrides alpha as matplotlib does [[#5700](https://github.com/plotly/plotly.py/pull/5700)], with thanks to @robertoffmoura for the contribution! - Export tick label colors to the plotly tickfont in `mpl_to_plotly` [[#5716](https://github.com/plotly/plotly.py/pull/5716)], 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! +- Fix `px.timeline` bars ending an hour early or late when timezone-aware dates span a daylight saving time change [[#5796](https://github.com/plotly/plotly.py/pull/5796)], with thanks to @mottopanikeiku for the contribution! ## [7.1.0] - 2026-09-15 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.