diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
index 8dda576063e0b..f7fa6cbea5ab0 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
@@ -170,6 +170,7 @@
"failed": "Failed",
"no_status": "No Status",
"none": "No Status",
+ "planned": "Planned",
"queued": "Queued",
"removed": "Removed",
"restarting": "Restarting",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
index a3086158bc405..37fc56222d14d 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
@@ -5,6 +5,33 @@
"reason": "Reason",
"title": "Dependencies Blocking Task From Getting Scheduled"
},
+ "calendar": {
+ "daily": "Daily",
+ "hourly": "Hourly",
+ "legend": {
+ "less": "Less",
+ "more": "More"
+ },
+ "navigation": {
+ "nextMonth": "Next month",
+ "nextYear": "Next year",
+ "previousMonth": "Previous month",
+ "previousYear": "Previous year"
+ },
+ "noData": "No data available",
+ "noRuns": "No runs",
+ "totalRuns": "Total Runs",
+ "week": "Week {{weekNumber}}",
+ "weekdays": {
+ "friday": "Fri",
+ "monday": "Mon",
+ "saturday": "Sat",
+ "sunday": "Sun",
+ "thursday": "Thu",
+ "tuesday": "Tue",
+ "wednesday": "Wed"
+ }
+ },
"code": {
"bundleUrl": "Bundle Url",
"noCode": "No Code Found",
@@ -101,6 +128,7 @@
"assetEvents": "Asset Events",
"auditLog": "Audit Log",
"backfills": "Backfills",
+ "calendar": "Calendar",
"code": "Code",
"details": "Details",
"logs": "Logs",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/common.json b/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/common.json
index 1423588781385..01bb01728d4cb 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/common.json
@@ -170,6 +170,7 @@
"failed": "失敗",
"no_status": "無狀態",
"none": "無狀態",
+ "planned": "已計劃",
"queued": "排隊中",
"removed": "已移除",
"restarting": "重啟中",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/dag.json b/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/dag.json
index 622b088679bb0..aefd59566eec3 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/dag.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/dag.json
@@ -5,6 +5,34 @@
"reason": "原因",
"title": "依賴 (Dependencies) 阻礙任務排程"
},
+ "calendar": {
+ "daily": "每日",
+ "error": "載入日曆資料時發生錯誤",
+ "hourly": "每小時",
+ "legend": {
+ "less": "較少",
+ "more": "較多"
+ },
+ "navigation": {
+ "nextMonth": "下一月",
+ "nextYear": "下一年",
+ "previousMonth": "上一月",
+ "previousYear": "上一年"
+ },
+ "noData": "沒有可用的資料",
+ "noRuns": "沒有執行",
+ "totalRuns": "總執行數",
+ "week": "第 {{weekNumber}} 週",
+ "weekdays": {
+ "friday": "五",
+ "monday": "一",
+ "saturday": "六",
+ "sunday": "日",
+ "thursday": "四",
+ "tuesday": "二",
+ "wednesday": "三"
+ }
+ },
"code": {
"bundleUrl": "套件包網址",
"noCode": "找不到程式碼",
@@ -101,6 +129,7 @@
"assetEvents": "資源事件",
"auditLog": "審計日誌",
"backfills": "回填",
+ "calendar": "日曆",
"code": "程式碼",
"details": "詳細資訊",
"logs": "日誌",
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
new file mode 100644
index 0000000000000..ec72c813ab133
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/Calendar.tsx
@@ -0,0 +1,270 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { Box, HStack, Text, IconButton, Button, ButtonGroup } from "@chakra-ui/react";
+import { keyframes } from "@emotion/react";
+import dayjs from "dayjs";
+import { useState, useMemo } from "react";
+import { useTranslation } from "react-i18next";
+import { FiChevronLeft, FiChevronRight } from "react-icons/fi";
+import { useParams } from "react-router-dom";
+import { useLocalStorage } from "usehooks-ts";
+
+import { useCalendarServiceGetCalendar } from "openapi/queries";
+import { ErrorAlert } from "src/components/ErrorAlert";
+
+import { CalendarLegend } from "./CalendarLegend";
+import { DailyCalendarView } from "./DailyCalendarView";
+import { HourlyCalendarView } from "./HourlyCalendarView";
+
+const spin = keyframes`
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+`;
+
+export const Calendar = () => {
+ const { dagId = "" } = useParams();
+ const { t: translate } = useTranslation("dag");
+ const [selectedDate, setSelectedDate] = useState(dayjs());
+ const [granularity, setGranularity] = useLocalStorage<"daily" | "hourly">("calendar-granularity", "hourly");
+ const [viewMode, setViewMode] = useLocalStorage<"failed" | "total">("calendar-view-mode", "total");
+
+ const currentDate = dayjs();
+
+ const dateRange = useMemo(() => {
+ if (granularity === "daily") {
+ const yearStart = selectedDate.startOf("year");
+ const yearEnd = selectedDate.endOf("year");
+
+ return {
+ logicalDateGte: yearStart.format("YYYY-MM-DD[T]HH:mm:ss[Z]"),
+ logicalDateLte: yearEnd.format("YYYY-MM-DD[T]HH:mm:ss[Z]"),
+ };
+ } else {
+ const monthStart = selectedDate.startOf("month");
+ const monthEnd = selectedDate.endOf("month");
+
+ return {
+ logicalDateGte: monthStart.format("YYYY-MM-DD[T]HH:mm:ss[Z]"),
+ logicalDateLte: monthEnd.format("YYYY-MM-DD[T]HH:mm:ss[Z]"),
+ };
+ }
+ }, [granularity, selectedDate]);
+
+ const { data, error, isLoading } = useCalendarServiceGetCalendar(
+ {
+ dagId,
+ granularity,
+ ...dateRange,
+ },
+ undefined,
+ { enabled: Boolean(dagId) },
+ );
+
+ if (!data && !isLoading) {
+ return (
+
+ {translate("calendar.noData")}
+
+ );
+ }
+
+ return (
+
+
+
+
+ {granularity === "daily" ? (
+
+ setSelectedDate(selectedDate.subtract(1, "year"))}
+ size="sm"
+ variant="ghost"
+ >
+
+
+ {
+ if (selectedDate.year() !== currentDate.year()) {
+ setSelectedDate(currentDate.startOf("year"));
+ }
+ }}
+ textAlign="center"
+ >
+ {selectedDate.year()}
+
+ setSelectedDate(selectedDate.add(1, "year"))}
+ size="sm"
+ variant="ghost"
+ >
+
+
+
+ ) : (
+
+ setSelectedDate(selectedDate.subtract(1, "month"))}
+ size="sm"
+ variant="ghost"
+ >
+
+
+ {
+ if (
+ !(selectedDate.isSame(currentDate, "month") && selectedDate.isSame(currentDate, "year"))
+ ) {
+ setSelectedDate(currentDate.startOf("month"));
+ }
+ }}
+ textAlign="center"
+ >
+ {selectedDate.format("MMM YYYY")}
+
+ setSelectedDate(selectedDate.add(1, "month"))}
+ size="sm"
+ variant="ghost"
+ >
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {isLoading ? (
+
+
+
+
+
+ ) : undefined}
+ {granularity === "daily" ? (
+ <>
+
+
+ >
+ ) : (
+
+
+
+
+
+
+
+
+ )}
+
+
+ );
+};
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarCell.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarCell.tsx
new file mode 100644
index 0000000000000..d10864fc15ea1
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarCell.tsx
@@ -0,0 +1,50 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { Box } from "@chakra-ui/react";
+
+import { CalendarTooltip } from "./CalendarTooltip";
+import { useDelayedTooltip } from "./useDelayedTooltip";
+
+type Props = {
+ readonly backgroundColor: Record | string;
+ readonly content: string;
+ readonly index?: number;
+ readonly marginRight?: string;
+};
+
+export const CalendarCell = ({ backgroundColor, content, index, marginRight }: Props) => {
+ const { handleMouseEnter, handleMouseLeave } = useDelayedTooltip();
+
+ const computedMarginRight = marginRight ?? (index !== undefined && index % 7 === 6 ? "8px" : "0");
+
+ return (
+
+
+
+
+ );
+};
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarLegend.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarLegend.tsx
new file mode 100644
index 0000000000000..d66ba21bb6f3c
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarLegend.tsx
@@ -0,0 +1,113 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { Box, HStack, Text, VStack } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+
+import { Tooltip } from "src/components/ui";
+
+import type { CalendarColorMode } from "./types";
+
+type Props = {
+ readonly colorMode: CalendarColorMode;
+ readonly vertical?: boolean;
+};
+
+const totalRunsLegendData = [
+ { color: { _dark: "gray.700", _light: "gray.100" }, label: "0" },
+ { color: { _dark: "green.300", _light: "green.200" }, label: "1-5" },
+ { color: { _dark: "green.500", _light: "green.400" }, label: "6-15" },
+ { color: { _dark: "green.700", _light: "green.600" }, label: "16-25" },
+ { color: { _dark: "green.900", _light: "green.800" }, label: "26+" },
+];
+
+const failedRunsLegendData = [
+ { color: { _dark: "gray.700", _light: "gray.100" }, label: "0" },
+ { color: { _dark: "red.300", _light: "red.200" }, label: "1-2" },
+ { color: { _dark: "red.500", _light: "red.400" }, label: "3-5" },
+ { color: { _dark: "red.700", _light: "red.600" }, label: "6-10" },
+ { color: { _dark: "red.900", _light: "red.800" }, label: "11+" },
+];
+
+export const CalendarLegend = ({ colorMode, vertical = false }: Props) => {
+ const { t: translate } = useTranslation("dag");
+
+ const legendData = colorMode === "total" ? totalRunsLegendData : failedRunsLegendData;
+ const legendTitle =
+ colorMode === "total" ? translate("calendar.totalRuns") : translate("overview.buttons.failedRun_other");
+
+ return (
+
+
+
+ {legendTitle}
+
+ {vertical ? (
+
+
+ {translate("calendar.legend.more")}
+
+
+ {[...legendData].reverse().map(({ color, label }) => (
+
+
+
+ ))}
+
+
+ {translate("calendar.legend.less")}
+
+
+ ) : (
+
+
+ {translate("calendar.legend.less")}
+
+
+ {legendData.map(({ color, label }) => (
+
+
+
+ ))}
+
+
+ {translate("calendar.legend.more")}
+
+
+ )}
+
+
+
+
+
+
+
+ {translate("common:states.planned")}
+
+
+
+
+
+ );
+};
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarTooltip.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarTooltip.tsx
new file mode 100644
index 0000000000000..16559614210ed
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarTooltip.tsx
@@ -0,0 +1,69 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { useMemo } from "react";
+
+type Props = {
+ readonly content: string;
+};
+
+export const CalendarTooltip = ({ content }: Props) => {
+ const tooltipStyle = useMemo(
+ () => ({
+ backgroundColor: "var(--chakra-colors-gray-800)",
+ borderRadius: "4px",
+ color: "white",
+ fontSize: "14px",
+ left: "50%",
+ opacity: 0,
+ padding: "8px",
+ pointerEvents: "none" as const,
+ position: "absolute" as const,
+ top: "22px",
+ transform: "translateX(-50%)",
+ transition: "opacity 0.2s, visibility 0.2s",
+ visibility: "hidden" as const,
+ whiteSpace: "nowrap" as const,
+ zIndex: 1000,
+ }),
+ [],
+ );
+
+ const arrowStyle = useMemo(
+ () => ({
+ borderBottom: "4px solid var(--chakra-colors-gray-800)",
+ borderLeft: "4px solid transparent",
+ borderRight: "4px solid transparent",
+ content: '""',
+ height: 0,
+ left: "50%",
+ position: "absolute" as const,
+ top: "-4px",
+ transform: "translateX(-50%)",
+ width: 0,
+ }),
+ [],
+ );
+
+ return (
+
+ );
+};
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
new file mode 100644
index 0000000000000..a0854b686fa93
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/DailyCalendarView.tsx
@@ -0,0 +1,127 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { Box, Text } from "@chakra-ui/react";
+import dayjs from "dayjs";
+import { useTranslation } from "react-i18next";
+
+import type { CalendarTimeRangeResponse } from "openapi/requests/types.gen";
+
+import { CalendarCell } from "./CalendarCell";
+import { createTooltipContent, generateDailyCalendarData, getCalendarCellColor } from "./calendarUtils";
+import type { CalendarColorMode } from "./types";
+
+type Props = {
+ readonly colorMode: CalendarColorMode;
+ readonly data: Array;
+ readonly selectedYear: number;
+};
+
+export const DailyCalendarView = ({ colorMode, data, selectedYear }: Props) => {
+ const { t: translate } = useTranslation("dag");
+ const dailyData = generateDailyCalendarData(data, selectedYear);
+
+ const weekdays = [
+ translate("calendar.weekdays.sunday"),
+ translate("calendar.weekdays.monday"),
+ translate("calendar.weekdays.tuesday"),
+ translate("calendar.weekdays.wednesday"),
+ translate("calendar.weekdays.thursday"),
+ translate("calendar.weekdays.friday"),
+ translate("calendar.weekdays.saturday"),
+ ];
+
+ return (
+
+
+
+
+ {dailyData.map((week, index) => (
+
+ {Boolean(week[0] && dayjs(week[0].date).date() <= 7) && (
+
+ {dayjs(week[0]?.date).format("MMM")}
+
+ )}
+
+ ))}
+
+
+
+
+ {weekdays.map((day) => (
+
+ {day}
+
+ ))}
+
+
+ {dailyData.map((week, weekIndex) => (
+
+ {week.map((day) => {
+ const dayDate = dayjs(day.date);
+ const isInSelectedYear = dayDate.year() === selectedYear;
+
+ if (!isInSelectedYear) {
+ return ;
+ }
+
+ return (
+
+ );
+ })}
+
+ ))}
+
+
+
+ );
+};
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
new file mode 100644
index 0000000000000..b4a9e5c152015
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/HourlyCalendarView.tsx
@@ -0,0 +1,193 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { Box, Text } from "@chakra-ui/react";
+import dayjs from "dayjs";
+import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
+import { useTranslation } from "react-i18next";
+
+import type { CalendarTimeRangeResponse } from "openapi/requests/types.gen";
+
+import { CalendarCell } from "./CalendarCell";
+import { createTooltipContent, generateHourlyCalendarData, getCalendarCellColor } from "./calendarUtils";
+import type { CalendarColorMode } from "./types";
+
+dayjs.extend(isSameOrBefore);
+
+type Props = {
+ readonly colorMode: CalendarColorMode;
+ readonly data: Array;
+ readonly selectedMonth: number;
+ readonly selectedYear: number;
+};
+
+export const HourlyCalendarView = ({ colorMode, data, selectedMonth, selectedYear }: Props) => {
+ const { t: translate } = useTranslation("dag");
+ const hourlyData = generateHourlyCalendarData(data, selectedYear, selectedMonth);
+
+ return (
+
+
+
+
+
+ {hourlyData.days.map((day, index) => {
+ const isFirstOfWeek = index % 7 === 0;
+ const weekNumber = Math.floor(index / 7) + 1;
+
+ return (
+
+ {Boolean(isFirstOfWeek) && (
+
+ {translate("calendar.week", { weekNumber })}
+
+ )}
+
+ );
+ })}
+
+
+
+
+
+ {hourlyData.days.map((day, index) => {
+ const dayOfWeek = dayjs(day.day).day();
+ const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
+ const dateFontSize = "2xs";
+ const dayNameFontSize = "2xs";
+ const dayName = dayjs(day.day).format("dd").charAt(0);
+
+ return (
+
+
+ {dayjs(day.day).format("D")}
+
+
+ {dayName}
+
+
+ );
+ })}
+
+
+
+
+
+
+ {Array.from({ length: 24 }, (_, hour) => (
+
+ {hour % 4 === 0 && hour.toString().padStart(2, "0")}
+
+ ))}
+
+
+ {Array.from({ length: 24 }, (_, hour) => (
+
+ {hourlyData.days.map((day, index) => {
+ const hourData = day.hours.find((hourItem) => hourItem.hour === hour);
+
+ if (!hourData) {
+ const noRunsTooltip = `${dayjs(day.day).format("MMM DD")}, ${hour.toString().padStart(2, "0")}:00 - ${translate("calendar.noRuns")}`;
+
+ return (
+
+ );
+ }
+
+ const tooltipContent =
+ hourData.counts.total > 0
+ ? `${dayjs(day.day).format("MMM DD")}, ${hour.toString().padStart(2, "0")}:00 - ${createTooltipContent(hourData).split(": ")[1]}`
+ : `${dayjs(day.day).format("MMM DD")}, ${hour.toString().padStart(2, "0")}:00 - ${translate("calendar.noRuns")}`;
+
+ return (
+
+ );
+ })}
+
+ ))}
+
+
+
+ );
+};
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
new file mode 100644
index 0000000000000..52b1cba570fea
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/calendarUtils.ts
@@ -0,0 +1,234 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import dayjs from "dayjs";
+import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
+
+import type { CalendarTimeRangeResponse } from "openapi/requests/types.gen";
+
+import type {
+ RunCounts,
+ DailyCalendarData,
+ HourlyCalendarData,
+ CalendarCellData,
+ CalendarColorMode,
+} from "./types";
+
+dayjs.extend(isSameOrBefore);
+
+const createDailyDataMap = (data: Array) => {
+ const dailyDataMap = new Map>();
+
+ data.forEach((run) => {
+ const dateStr = run.date.slice(0, 10); // "YYYY-MM-DD"
+ const dailyRuns = dailyDataMap.get(dateStr);
+
+ if (dailyRuns) {
+ dailyRuns.push(run);
+ } else {
+ dailyDataMap.set(dateStr, [run]);
+ }
+ });
+
+ return dailyDataMap;
+};
+
+const createHourlyDataMap = (data: Array) => {
+ const hourlyDataMap = new Map>();
+
+ data.forEach((run) => {
+ const hourStr = run.date.slice(0, 13); // "YYYY-MM-DDTHH"
+ const hourlyRuns = hourlyDataMap.get(hourStr);
+
+ if (hourlyRuns) {
+ hourlyRuns.push(run);
+ } else {
+ hourlyDataMap.set(hourStr, [run]);
+ }
+ });
+
+ return hourlyDataMap;
+};
+
+export const calculateRunCounts = (runs: Array): RunCounts => {
+ const counts: { [K in keyof RunCounts]: number } = {
+ failed: 0,
+ planned: 0,
+ queued: 0,
+ running: 0,
+ success: 0,
+ total: 0,
+ };
+
+ runs.forEach((run) => {
+ const { count, state } = run;
+
+ if (state in counts) {
+ counts[state] += count;
+ }
+ counts.total += count;
+ });
+
+ return counts as RunCounts;
+};
+
+const TOTAL_COLOR_INTENSITIES = [
+ { _dark: "gray.700", _light: "gray.100" }, // 0 runs
+ { _dark: "green.300", _light: "green.200" }, // 1-5 runs
+ { _dark: "green.500", _light: "green.400" }, // 6-15 runs
+ { _dark: "green.700", _light: "green.600" }, // 16-25 runs
+ { _dark: "green.900", _light: "green.800" }, // 26+ runs
+] as const;
+
+const FAILURE_COLOR_INTENSITIES = [
+ { _dark: "gray.700", _light: "gray.100" }, // 0 failures
+ { _dark: "red.300", _light: "red.200" }, // 1-2 failures
+ { _dark: "red.500", _light: "red.400" }, // 3-5 failures
+ { _dark: "red.700", _light: "red.600" }, // 6-10 failures
+ { _dark: "red.900", _light: "red.800" }, // 11+ failures
+] as const;
+
+const PLANNED_COLOR = { _dark: "scheduled.600", _light: "scheduled.200" };
+
+const getIntensityLevel = (count: number, mode: CalendarColorMode): number => {
+ if (count === 0) {
+ return 0;
+ }
+
+ if (mode === "total") {
+ if (count <= 5) {
+ return 1;
+ }
+ if (count <= 15) {
+ return 2;
+ }
+ if (count <= 25) {
+ return 3;
+ }
+
+ return 4;
+ } else {
+ // failed runs mode
+ if (count <= 2) {
+ return 1;
+ }
+ if (count <= 5) {
+ return 2;
+ }
+ if (count <= 10) {
+ return 3;
+ }
+
+ return 4;
+ }
+};
+
+export const getCalendarCellColor = (
+ runs: Array,
+ colorMode: CalendarColorMode = "total",
+): string | { _dark: string; _light: string } => {
+ if (runs.length === 0) {
+ return { _dark: "gray.700", _light: "gray.100" };
+ }
+
+ const counts = calculateRunCounts(runs);
+
+ if (counts.planned > 0) {
+ return PLANNED_COLOR;
+ }
+
+ const targetCount = colorMode === "total" ? counts.total : counts.failed;
+ const intensityLevel = getIntensityLevel(targetCount, colorMode);
+ const colorScheme = colorMode === "total" ? TOTAL_COLOR_INTENSITIES : FAILURE_COLOR_INTENSITIES;
+
+ return colorScheme[intensityLevel] ?? { _dark: "gray.700", _light: "gray.100" };
+};
+
+export const generateDailyCalendarData = (
+ data: Array,
+ selectedYear: number,
+): DailyCalendarData => {
+ const dailyDataMap = createDailyDataMap(data);
+
+ const weeks = [];
+ const startOfYear = dayjs().year(selectedYear).startOf("year");
+ const endOfYear = dayjs().year(selectedYear).endOf("year");
+
+ let currentDate = startOfYear.startOf("week");
+ const endDate = endOfYear.endOf("week");
+
+ while (currentDate.isBefore(endDate) || currentDate.isSame(endDate, "day")) {
+ const week = [];
+
+ for (let dayIndex = 0; dayIndex < 7; dayIndex += 1) {
+ const dateStr = currentDate.format("YYYY-MM-DD");
+ const runs = dailyDataMap.get(dateStr) ?? [];
+ const counts = calculateRunCounts(runs);
+
+ week.push({ counts, date: dateStr, runs });
+ currentDate = currentDate.add(1, "day");
+ }
+ weeks.push(week);
+ }
+
+ return weeks;
+};
+
+export const generateHourlyCalendarData = (
+ data: Array,
+ selectedYear: number,
+ selectedMonth: number,
+): HourlyCalendarData => {
+ const hourlyDataMap = createHourlyDataMap(data);
+
+ const monthStart = dayjs().year(selectedYear).month(selectedMonth).startOf("month");
+ const monthEnd = dayjs().year(selectedYear).month(selectedMonth).endOf("month");
+ const monthData = [];
+
+ let currentDate = monthStart;
+
+ while (currentDate.isSameOrBefore(monthEnd, "day")) {
+ const dayHours = [];
+
+ for (let hour = 0; hour < 24; hour += 1) {
+ const hourStr = currentDate.hour(hour).format("YYYY-MM-DDTHH");
+ const runs = hourlyDataMap.get(hourStr) ?? [];
+ const counts = calculateRunCounts(runs);
+
+ dayHours.push({ counts, date: `${hourStr}:00:00`, hour, runs });
+ }
+ monthData.push({ day: currentDate.format("YYYY-MM-DD"), hours: dayHours });
+ currentDate = currentDate.add(1, "day");
+ }
+
+ return { days: monthData, month: monthStart.format("MMM YYYY") };
+};
+
+export const createTooltipContent = (cellData: CalendarCellData): string => {
+ const { counts, date } = cellData;
+
+ if (counts.total === 0) {
+ return `${date}: No runs`;
+ }
+
+ const parts = Object.entries(counts)
+ .filter(([key, value]) => key !== "total" && value > 0)
+ .map(([state, count]) => `${count} ${state}`);
+
+ return `${date}: ${counts.total} runs (${parts.join(", ")})`;
+};
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/index.ts b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/index.ts
new file mode 100644
index 0000000000000..85ffa1abfc2f6
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/index.ts
@@ -0,0 +1,25 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export { Calendar } from "./Calendar";
+export { CalendarLegend } from "./CalendarLegend";
+export { DailyCalendarView } from "./DailyCalendarView";
+export { HourlyCalendarView } from "./HourlyCalendarView";
+export type * from "./types";
+export * from "./calendarUtils";
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/types.ts b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/types.ts
new file mode 100644
index 0000000000000..832e71f1e2713
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/types.ts
@@ -0,0 +1,58 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import type { CalendarTimeRangeResponse } from "openapi/requests/types.gen";
+
+export type DagRunState = "failed" | "planned" | "queued" | "running" | "success";
+
+export type RunCounts = {
+ failed: number;
+ planned: number;
+ queued: number;
+ running: number;
+ success: number;
+ total: number;
+};
+
+export type CalendarCellData = {
+ readonly counts: RunCounts;
+ readonly date: string;
+ readonly runs: Array;
+};
+
+export type DayData = CalendarCellData;
+
+export type HourData = {
+ readonly hour: number;
+} & CalendarCellData;
+
+export type WeekData = Array;
+
+export type DailyCalendarData = Array;
+
+export type HourlyCalendarData = {
+ readonly days: Array<{
+ readonly day: string;
+ readonly hours: Array;
+ }>;
+ readonly month: string;
+};
+
+export type CalendarGranularity = "daily" | "hourly";
+
+export type CalendarColorMode = "failed" | "total";
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/useDelayedTooltip.ts b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/useDelayedTooltip.ts
new file mode 100644
index 0000000000000..b2e0d17c0ea72
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/useDelayedTooltip.ts
@@ -0,0 +1,60 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { useRef } from "react";
+
+export const useDelayedTooltip = (delayMs: number = 200) => {
+ const debounceTimeoutRef = useRef(undefined);
+ const activeTooltipRef = useRef(undefined);
+
+ const handleMouseEnter = (event: React.MouseEvent) => {
+ if (debounceTimeoutRef.current) {
+ clearTimeout(debounceTimeoutRef.current);
+ }
+
+ const tooltipElement = event.currentTarget.querySelector("[data-tooltip]");
+
+ if (tooltipElement) {
+ activeTooltipRef.current = tooltipElement as HTMLElement;
+ debounceTimeoutRef.current = setTimeout(() => {
+ if (activeTooltipRef.current) {
+ activeTooltipRef.current.style.opacity = "1";
+ activeTooltipRef.current.style.visibility = "visible";
+ }
+ }, delayMs);
+ }
+ };
+
+ const handleMouseLeave = () => {
+ if (debounceTimeoutRef.current) {
+ clearTimeout(debounceTimeoutRef.current);
+ debounceTimeoutRef.current = undefined;
+ }
+
+ if (activeTooltipRef.current) {
+ activeTooltipRef.current.style.opacity = "0";
+ activeTooltipRef.current.style.visibility = "hidden";
+ activeTooltipRef.current = undefined;
+ }
+ };
+
+ return {
+ handleMouseEnter,
+ handleMouseLeave,
+ };
+};
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Dag.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/Dag.tsx
index 455dbc2e7941a..3a1fff709487c 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Dag.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Dag.tsx
@@ -19,7 +19,7 @@
import { ReactFlowProvider } from "@xyflow/react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
-import { FiBarChart, FiCode, FiUser } from "react-icons/fi";
+import { FiBarChart, FiCode, FiUser, FiCalendar } from "react-icons/fi";
import { LuChartColumn } from "react-icons/lu";
import { MdDetails, MdOutlineEventNote } from "react-icons/md";
import { RiArrowGoBackFill } from "react-icons/ri";
@@ -49,6 +49,7 @@ export const Dag = () => {
{ icon: , label: translate("tabs.overview"), value: "" },
{ icon: , label: translate("tabs.runs"), value: "runs" },
{ icon: , label: translate("tabs.tasks"), value: "tasks" },
+ { icon: , label: translate("tabs.calendar"), value: "calendar" },
{ icon: , label: translate("tabs.requiredActions"), value: "required_actions" },
{ icon: , label: translate("tabs.backfills"), value: "backfills" },
{ icon: , label: translate("tabs.auditLog"), value: "events" },
diff --git a/airflow-core/src/airflow/ui/src/router.tsx b/airflow-core/src/airflow/ui/src/router.tsx
index 07ac8b7dd563e..093ea1b45bb05 100644
--- a/airflow-core/src/airflow/ui/src/router.tsx
+++ b/airflow-core/src/airflow/ui/src/router.tsx
@@ -29,6 +29,7 @@ import { Configs } from "src/pages/Configs";
import { Connections } from "src/pages/Connections";
import { Dag } from "src/pages/Dag";
import { Backfills } from "src/pages/Dag/Backfills";
+import { Calendar } from "src/pages/Dag/Calendar/Calendar";
import { Code } from "src/pages/Dag/Code";
import { Details as DagDetails } from "src/pages/Dag/Details";
import { Overview } from "src/pages/Dag/Overview";
@@ -161,6 +162,7 @@ export const routerConfig = [
{ element: , index: true },
{ element: , path: "runs" },
{ element: , path: "tasks" },
+ { element: , path: "calendar" },
{ element: , path: "required_actions" },
{ element: , path: "backfills" },
{ element: , path: "events" },
diff --git a/airflow-core/src/airflow/ui/src/theme.ts b/airflow-core/src/airflow/ui/src/theme.ts
index 706ae0ed57e41..88bac3fd19299 100644
--- a/airflow-core/src/airflow/ui/src/theme.ts
+++ b/airflow-core/src/airflow/ui/src/theme.ts
@@ -40,8 +40,8 @@ const customConfig = defineConfig({
"100": { value: "#C2FFC2" },
"200": { value: "#80FF80" },
"300": { value: "#42FF42" },
- "400": { value: "#00FF00" },
- "500": { value: "#00C200" },
+ "400": { value: "#22C55E" },
+ "500": { value: "#16A34A" },
"600": { value: "#008000" },
"700": { value: "#006100" },
"800": { value: "#004200" },
diff --git a/dev/react-plugin-tools/react_plugin_template/src/theme.ts b/dev/react-plugin-tools/react_plugin_template/src/theme.ts
index 706ae0ed57e41..88bac3fd19299 100644
--- a/dev/react-plugin-tools/react_plugin_template/src/theme.ts
+++ b/dev/react-plugin-tools/react_plugin_template/src/theme.ts
@@ -40,8 +40,8 @@ const customConfig = defineConfig({
"100": { value: "#C2FFC2" },
"200": { value: "#80FF80" },
"300": { value: "#42FF42" },
- "400": { value: "#00FF00" },
- "500": { value: "#00C200" },
+ "400": { value: "#22C55E" },
+ "500": { value: "#16A34A" },
"600": { value: "#008000" },
"700": { value: "#006100" },
"800": { value: "#004200" },