This is an automated email from the ASF dual-hosted git repository.
pierrejeambrun pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 7d66aa7cdee fix(ui): Calendar view respects user-selected timezone
(#67497)
7d66aa7cdee is described below
commit 7d66aa7cdee6cb11077880cea097bd91d78de2aa
Author: Anmol Mishra <[email protected]>
AuthorDate: Wed Jun 3 17:12:14 2026 +0530
fix(ui): Calendar view respects user-selected timezone (#67497)
* 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
---------
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 04c04c4464e..c8ed293a62f 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, 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";
@@ -30,6 +32,7 @@ import { ErrorAlert } from "src/components/ErrorAlert";
import { IconButton } from "src/components/ui";
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";
@@ -41,6 +44,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");
@@ -53,14 +59,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(
{
@@ -74,7 +84,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 (
@@ -226,6 +240,7 @@ export const Calendar = () => {
data-testid="calendar-daily-view"
scale={scale}
selectedYear={selectedDate.year()}
+ timezone={selectedTimezone}
viewMode={viewMode}
/>
<CalendarLegend scale={scale} viewMode={viewMode} />
@@ -238,6 +253,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 fb8eef52d3c..3f57eb35030 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
@@ -18,6 +18,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";
@@ -32,6 +34,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" };
@@ -58,11 +62,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) {
@@ -75,11 +79,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) {
@@ -117,12 +121,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");
@@ -144,15 +149,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;
@@ -174,11 +185,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 };
}
@@ -186,7 +204,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);
@@ -220,12 +238,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) {