This is an automated email from the ASF dual-hosted git repository.

pierrejeambrun pushed a commit to branch v3-2-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-2-test by this push:
     new 1dd82b2e40a [v3-2-test] fix(ui): Calendar view respects user-selected 
timezone (#67497) (#67954)
1dd82b2e40a is described below

commit 1dd82b2e40a0faddd52b90cbdfdf4adeab4be5d6
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Wed Jun 3 15:49:34 2026 +0200

    [v3-2-test] fix(ui): Calendar view respects user-selected timezone (#67497) 
(#67954)
    
    * fix(ui): make Calendar view respect user-selected timezone
    
    Thread selectedTimezone from useTimezone() through the Calendar
    component tree. Compute date ranges in the selected timezone before
    converting to UTC for API queries. Group Dag runs by their local
    date/hour in the selected timezone instead of raw UTC string slicing.
    
    Fixes #67477
    
    * fix: refactor calendarUtils params to options objects to satisfy 
@typescript-eslint/max-params
    
    ---------
    (cherry picked from commit 7d66aa7cdee6cb11077880cea097bd91d78de2aa)
    
    Co-authored-by: Anmol Mishra <[email protected]>
    Co-authored-by: Anmol Mishra <[email protected]>
---
 .../airflow/ui/src/pages/Dag/Calendar/Calendar.tsx | 26 ++++++--
 .../src/pages/Dag/Calendar/DailyCalendarView.tsx   |  5 +-
 .../src/pages/Dag/Calendar/HourlyCalendarView.tsx  |  4 +-
 .../src/pages/Dag/Calendar/calendarUtils.test.ts   | 71 +++++++++++++++++-----
 .../ui/src/pages/Dag/Calendar/calendarUtils.ts     | 60 ++++++++++++------
 5 files changed, 124 insertions(+), 42 deletions(-)

diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/Calendar.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/Calendar.tsx
index 807c3a93769..e6fd707fc59 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/Calendar.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/Calendar.tsx
@@ -19,6 +19,8 @@
 import { Box, HStack, IconButton, Text } from "@chakra-ui/react";
 import { keyframes } from "@emotion/react";
 import dayjs from "dayjs";
+import tz from "dayjs/plugin/timezone";
+import utc from "dayjs/plugin/utc";
 import { useState } from "react";
 import { useTranslation } from "react-i18next";
 import { FiChevronLeft, FiChevronRight } from "react-icons/fi";
@@ -29,6 +31,7 @@ import { useCalendarServiceGetCalendar, 
useDagServiceGetDagDetails } from "opena
 import { ErrorAlert } from "src/components/ErrorAlert";
 import { ButtonGroupToggle } from "src/components/ui/ButtonGroupToggle";
 import { CALENDAR_GRANULARITY_KEY, CALENDAR_VIEW_MODE_KEY } from 
"src/constants/localStorage";
+import { useTimezone } from "src/context/timezone";
 
 import { CalendarLegend } from "./CalendarLegend";
 import { DailyCalendarView } from "./DailyCalendarView";
@@ -40,6 +43,9 @@ const spin = keyframes`
   to { transform: rotate(360deg); }
 `;
 
+dayjs.extend(utc);
+dayjs.extend(tz);
+
 export const Calendar = () => {
   const { dagId = "" } = useParams();
   const { t: translate } = useTranslation("dag");
@@ -52,14 +58,18 @@ export const Calendar = () => {
 
   const currentDate = dayjs();
 
+  const { selectedTimezone } = useTimezone();
+
   const { data: dag } = useDagServiceGetDagDetails({ dagId });
   const isPartitioned = dag?.timetable_partitioned ?? false;
 
-  const startDate = granularity === "daily" ? selectedDate.startOf("year") : 
selectedDate.startOf("month");
-  const endDate = granularity === "daily" ? selectedDate.endOf("year") : 
selectedDate.endOf("month");
+  // Compute the date range in the selected timezone, then convert to UTC for 
API
+  const tzDate = selectedDate.tz(selectedTimezone, true);
+  const startDate = granularity === "daily" ? tzDate.startOf("year") : 
tzDate.startOf("month");
+  const endDate = granularity === "daily" ? tzDate.endOf("year") : 
tzDate.endOf("month");
 
-  const gte = startDate.format("YYYY-MM-DD[T]HH:mm:ss[Z]");
-  const lte = endDate.format("YYYY-MM-DD[T]HH:mm:ss[Z]");
+  const gte = startDate.utc().format("YYYY-MM-DDTHH:mm:ss.SSS[Z]");
+  const lte = endDate.utc().format("YYYY-MM-DDTHH:mm:ss.SSS[Z]");
 
   const { data, error, isLoading } = useCalendarServiceGetCalendar(
     {
@@ -73,7 +83,11 @@ export const Calendar = () => {
     { enabled: Boolean(dagId) },
   );
 
-  const scale = createCalendarScale(data?.dag_runs ?? [], viewMode, 
granularity);
+  const scale = createCalendarScale(data?.dag_runs ?? [], {
+    granularity,
+    timezone: selectedTimezone,
+    viewMode,
+  });
 
   if (!data && !isLoading) {
     return (
@@ -233,6 +247,7 @@ export const Calendar = () => {
               data-testid="calendar-daily-view"
               scale={scale}
               selectedYear={selectedDate.year()}
+              timezone={selectedTimezone}
               viewMode={viewMode}
             />
             <CalendarLegend scale={scale} viewMode={viewMode} />
@@ -245,6 +260,7 @@ export const Calendar = () => {
                 scale={scale}
                 selectedMonth={selectedDate.month()}
                 selectedYear={selectedDate.year()}
+                timezone={selectedTimezone}
                 viewMode={viewMode}
               />
             </Box>
diff --git 
a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/DailyCalendarView.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/DailyCalendarView.tsx
index c34a22c8b78..8127d550e6c 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/DailyCalendarView.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/DailyCalendarView.tsx
@@ -49,12 +49,13 @@ type Props = {
   readonly data: Array<CalendarTimeRangeResponse>;
   readonly scale: CalendarScale;
   readonly selectedYear: number;
+  readonly timezone: string;
   readonly viewMode?: CalendarColorMode;
 };
 
-export const DailyCalendarView = ({ data, scale, selectedYear, viewMode = 
"total" }: Props) => {
+export const DailyCalendarView = ({ data, scale, selectedYear, timezone, 
viewMode = "total" }: Props) => {
   const { t: translate } = useTranslation("dag");
-  const dailyData = generateDailyCalendarData(data, selectedYear);
+  const dailyData = generateDailyCalendarData(data, selectedYear, timezone);
 
   const weekdays = [
     translate("calendar.weekdays.sunday"),
diff --git 
a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/HourlyCalendarView.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/HourlyCalendarView.tsx
index b700852bad0..88db41d723a 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/HourlyCalendarView.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/HourlyCalendarView.tsx
@@ -53,6 +53,7 @@ type Props = {
   readonly scale: CalendarScale;
   readonly selectedMonth: number;
   readonly selectedYear: number;
+  readonly timezone: string;
   readonly viewMode?: CalendarColorMode;
 };
 
@@ -61,10 +62,11 @@ export const HourlyCalendarView = ({
   scale,
   selectedMonth,
   selectedYear,
+  timezone,
   viewMode = "total",
 }: Props) => {
   const { t: translate } = useTranslation("dag");
-  const hourlyData = generateHourlyCalendarData(data, selectedYear, 
selectedMonth);
+  const hourlyData = generateHourlyCalendarData(data, { selectedMonth, 
selectedYear, timezone });
 
   return (
     <Box data-testid="calendar-hourly-view" mb={4}>
diff --git 
a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/calendarUtils.test.ts 
b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/calendarUtils.test.ts
index 1dec1580283..4cb86deec29 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/calendarUtils.test.ts
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/calendarUtils.test.ts
@@ -77,8 +77,7 @@ describe("calculateDataBounds", () => {
           run("failed", 1, "2026-04-08T10:00:00Z"),
           run("running", 4, "2026-04-08T11:00:00Z"),
         ],
-        "total",
-        "hourly",
+        { granularity: "hourly", timezone: "UTC", viewMode: "total" },
       ),
     ).toEqual({ maxCount: 4, minCount: 3 });
   });
@@ -87,14 +86,19 @@ describe("calculateDataBounds", () => {
     expect(
       calculateDataBounds(
         [run("queued", 100, "2026-04-08T10:00:00Z"), run("success", 1, 
"2026-04-08T11:00:00Z")],
-        "total",
-        "hourly",
+        { granularity: "hourly", timezone: "UTC", viewMode: "total" },
       ),
     ).toEqual({ maxCount: 1, minCount: 1 });
   });
 
   it("keeps queued-only total mode data from using an empty scale", () => {
-    expect(calculateDataBounds([run("queued", 100)], "total", 
"hourly")).toEqual({
+    expect(
+      calculateDataBounds([run("queued", 100)], {
+        granularity: "hourly",
+        timezone: "UTC",
+        viewMode: "total",
+      }),
+    ).toEqual({
       maxCount: 100,
       minCount: 100,
     });
@@ -109,8 +113,7 @@ describe("calculateDataBounds", () => {
           run("failed", 5, "2026-04-08T11:00:00Z"),
           run("queued", 20, "2026-04-08T11:00:00Z"),
         ],
-        "failed",
-        "hourly",
+        { granularity: "hourly", timezone: "UTC", viewMode: "failed" },
       ),
     ).toEqual({ maxCount: 5, minCount: 2 });
   });
@@ -118,25 +121,41 @@ describe("calculateDataBounds", () => {
 
 describe("createCalendarScale", () => {
   it("returns the planned color for a planned-only cell", () => {
-    const scale = createCalendarScale([run("planned", 1)], "total", "hourly");
+    const scale = createCalendarScale([run("planned", 1)], {
+      granularity: "hourly",
+      timezone: "UTC",
+      viewMode: "total",
+    });
 
     expect(scale.getColor({ ...EMPTY_COUNTS, planned: 1, total: 1 
})).toEqual(PLANNED_COLOR);
   });
 
   it("returns the default total color for a success-only cell", () => {
-    const scale = createCalendarScale([run("success", 1)], "total", "hourly");
+    const scale = createCalendarScale([run("success", 1)], {
+      granularity: "hourly",
+      timezone: "UTC",
+      viewMode: "total",
+    });
 
     expect(scale.getColor({ ...EMPTY_COUNTS, success: 1, total: 1 
})).toEqual(DEFAULT_TOTAL_COLOR);
   });
 
   it("returns the planned color for a queued-only cell in total mode", () => {
-    const scale = createCalendarScale([run("queued", 1)], "total", "hourly");
+    const scale = createCalendarScale([run("queued", 1)], {
+      granularity: "hourly",
+      timezone: "UTC",
+      viewMode: "total",
+    });
 
     expect(scale.getColor({ ...EMPTY_COUNTS, queued: 1, total: 1 
})).toEqual(PLANNED_COLOR);
   });
 
   it("returns a mixed color for planned and actual runs in total mode", () => {
-    const scale = createCalendarScale([run("planned", 1), run("success", 1)], 
"total", "hourly");
+    const scale = createCalendarScale([run("planned", 1), run("success", 1)], {
+      granularity: "hourly",
+      timezone: "UTC",
+      viewMode: "total",
+    });
 
     expect(scale.getColor({ ...EMPTY_COUNTS, planned: 1, success: 1, total: 2 
})).toEqual({
       actual: DEFAULT_TOTAL_COLOR,
@@ -145,7 +164,11 @@ describe("createCalendarScale", () => {
   });
 
   it("returns a mixed color for queued and actual runs in total mode", () => {
-    const scale = createCalendarScale([run("queued", 1), run("success", 1)], 
"total", "hourly");
+    const scale = createCalendarScale([run("queued", 1), run("success", 1)], {
+      granularity: "hourly",
+      timezone: "UTC",
+      viewMode: "total",
+    });
 
     expect(scale.getColor({ ...EMPTY_COUNTS, queued: 1, success: 1, total: 2 
})).toEqual({
       actual: DEFAULT_TOTAL_COLOR,
@@ -154,14 +177,22 @@ describe("createCalendarScale", () => {
   });
 
   it("uses failed counts for failed mode", () => {
-    const scale = createCalendarScale([run("success", 5), run("failed", 1)], 
"failed", "hourly");
+    const scale = createCalendarScale([run("success", 5), run("failed", 1)], {
+      granularity: "hourly",
+      timezone: "UTC",
+      viewMode: "failed",
+    });
 
     expect(scale.getColor({ ...EMPTY_COUNTS, success: 5, total: 5 
})).toEqual(EMPTY_COLOR);
     expect(scale.getColor({ ...EMPTY_COUNTS, failed: 1, total: 1 
})).toEqual(DEFAULT_FAILED_COLOR);
   });
 
   it("returns a mixed color for planned and failed runs in failed mode", () => 
{
-    const scale = createCalendarScale([run("planned", 1), run("failed", 1)], 
"failed", "hourly");
+    const scale = createCalendarScale([run("planned", 1), run("failed", 1)], {
+      granularity: "hourly",
+      timezone: "UTC",
+      viewMode: "failed",
+    });
 
     expect(scale.getColor({ ...EMPTY_COUNTS, failed: 1, planned: 1, total: 2 
})).toEqual({
       actual: DEFAULT_FAILED_COLOR,
@@ -170,13 +201,21 @@ describe("createCalendarScale", () => {
   });
 
   it("returns the planned color for a queued-only cell in failed mode", () => {
-    const scale = createCalendarScale([run("queued", 1)], "failed", "hourly");
+    const scale = createCalendarScale([run("queued", 1)], {
+      granularity: "hourly",
+      timezone: "UTC",
+      viewMode: "failed",
+    });
 
     expect(scale.getColor({ ...EMPTY_COUNTS, queued: 1, total: 1 
})).toEqual(PLANNED_COLOR);
   });
 
   it("returns a mixed color for queued and failed runs in failed mode", () => {
-    const scale = createCalendarScale([run("queued", 1), run("failed", 1)], 
"failed", "hourly");
+    const scale = createCalendarScale([run("queued", 1), run("failed", 1)], {
+      granularity: "hourly",
+      timezone: "UTC",
+      viewMode: "failed",
+    });
 
     expect(scale.getColor({ ...EMPTY_COUNTS, failed: 1, queued: 1, total: 2 
})).toEqual({
       actual: DEFAULT_FAILED_COLOR,
diff --git 
a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/calendarUtils.ts 
b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/calendarUtils.ts
index 712699f5707..63cfa411457 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/calendarUtils.ts
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/calendarUtils.ts
@@ -20,6 +20,8 @@
  */
 import dayjs from "dayjs";
 import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
+import tz from "dayjs/plugin/timezone";
+import utc from "dayjs/plugin/utc";
 
 import type { CalendarTimeRangeResponse } from "openapi/requests/types.gen";
 
@@ -34,6 +36,8 @@ import type {
 } from "./types";
 
 dayjs.extend(isSameOrBefore);
+dayjs.extend(utc);
+dayjs.extend(tz);
 
 // Calendar color constants
 export const PLANNED_COLOR = { _dark: "stone.600", _light: "stone.500" };
@@ -60,11 +64,11 @@ const getActualRunCount = (counts: RunCounts, viewMode: 
CalendarColorMode) =>
 
 const getPendingRunCount = (counts: RunCounts) => counts.planned + 
counts.queued;
 
-const createDailyDataMap = (data: Array<CalendarTimeRangeResponse>) => {
+const createDailyDataMap = (data: Array<CalendarTimeRangeResponse>, timezone: 
string) => {
   const dailyDataMap = new Map<string, Array<CalendarTimeRangeResponse>>();
 
   data.forEach((run) => {
-    const dateStr = run.date.slice(0, 10); // "YYYY-MM-DD"
+    const dateStr = dayjs(run.date).tz(timezone).format("YYYY-MM-DD");
     const dailyRuns = dailyDataMap.get(dateStr);
 
     if (dailyRuns) {
@@ -77,11 +81,11 @@ const createDailyDataMap = (data: 
Array<CalendarTimeRangeResponse>) => {
   return dailyDataMap;
 };
 
-const createHourlyDataMap = (data: Array<CalendarTimeRangeResponse>) => {
+const createHourlyDataMap = (data: Array<CalendarTimeRangeResponse>, timezone: 
string) => {
   const hourlyDataMap = new Map<string, Array<CalendarTimeRangeResponse>>();
 
   data.forEach((run) => {
-    const hourStr = run.date.slice(0, 13); // "YYYY-MM-DDTHH"
+    const hourStr = dayjs(run.date).tz(timezone).format("YYYY-MM-DDTHH");
     const hourlyRuns = hourlyDataMap.get(hourStr);
 
     if (hourlyRuns) {
@@ -119,12 +123,13 @@ export const calculateRunCounts = (runs: 
Array<CalendarTimeRangeResponse>): RunC
 export const generateDailyCalendarData = (
   data: Array<CalendarTimeRangeResponse>,
   selectedYear: number,
+  timezone: string,
 ): DailyCalendarData => {
-  const dailyDataMap = createDailyDataMap(data);
+  const dailyDataMap = createDailyDataMap(data, timezone);
 
   const weeks = [];
-  const startOfYear = dayjs().year(selectedYear).startOf("year");
-  const endOfYear = dayjs().year(selectedYear).endOf("year");
+  const startOfYear = dayjs().tz(timezone).year(selectedYear).startOf("year");
+  const endOfYear = dayjs().tz(timezone).year(selectedYear).endOf("year");
 
   let currentDate = startOfYear.startOf("week");
   const endDate = endOfYear.endOf("week");
@@ -146,15 +151,21 @@ export const generateDailyCalendarData = (
   return weeks;
 };
 
+type HourlyOptions = {
+  selectedMonth: number;
+  selectedYear: number;
+  timezone: string;
+};
+
 export const generateHourlyCalendarData = (
   data: Array<CalendarTimeRangeResponse>,
-  selectedYear: number,
-  selectedMonth: number,
+  options: HourlyOptions,
 ): HourlyCalendarData => {
-  const hourlyDataMap = createHourlyDataMap(data);
+  const { selectedMonth, selectedYear, timezone } = options;
+  const hourlyDataMap = createHourlyDataMap(data, timezone);
 
-  const monthStart = 
dayjs().year(selectedYear).month(selectedMonth).startOf("month");
-  const monthEnd = 
dayjs().year(selectedYear).month(selectedMonth).endOf("month");
+  const monthStart = 
dayjs().tz(timezone).year(selectedYear).month(selectedMonth).startOf("month");
+  const monthEnd = 
dayjs().tz(timezone).year(selectedYear).month(selectedMonth).endOf("month");
   const monthData = [];
 
   let currentDate = monthStart;
@@ -176,11 +187,18 @@ export const generateHourlyCalendarData = (
   return { days: monthData, month: monthStart.format("MMM YYYY") };
 };
 
+type BoundsOptions = {
+  granularity: CalendarGranularity;
+  timezone: string;
+  viewMode: CalendarColorMode;
+};
+
 export const calculateDataBounds = (
   data: Array<CalendarTimeRangeResponse>,
-  viewMode: CalendarColorMode,
-  granularity: CalendarGranularity,
+  options: BoundsOptions,
 ): { maxCount: number; minCount: number } => {
+  const { granularity, timezone, viewMode } = options;
+
   if (data.length === 0) {
     return { maxCount: 0, minCount: 0 };
   }
@@ -188,7 +206,7 @@ export const calculateDataBounds = (
   const counts: Array<number> = [];
   const pendingCounts: Array<number> = [];
   const mapCreator = granularity === "daily" ? createDailyDataMap : 
createHourlyDataMap;
-  const dataMap = mapCreator(data);
+  const dataMap = mapCreator(data, timezone);
 
   dataMap.forEach((runs) => {
     const runCounts = calculateRunCounts(runs);
@@ -222,12 +240,18 @@ export const calculateDataBounds = (
   };
 };
 
+type ScaleOptions = {
+  granularity: CalendarGranularity;
+  timezone: string;
+  viewMode: CalendarColorMode;
+};
+
 export const createCalendarScale = (
   data: Array<CalendarTimeRangeResponse>,
-  viewMode: CalendarColorMode,
-  granularity: CalendarGranularity,
+  options: ScaleOptions,
 ): CalendarScale => {
-  const { maxCount, minCount } = calculateDataBounds(data, viewMode, 
granularity);
+  const { granularity, timezone, viewMode } = options;
+  const { maxCount, minCount } = calculateDataBounds(data, { granularity, 
timezone, viewMode });
 
   // Handle empty data case
   if (maxCount === 0) {

Reply via email to