This is an automated email from the ASF dual-hosted git repository.
bbovenzi 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 9a30311195d Add Calendar view for Dag (#54252)
9a30311195d is described below
commit 9a30311195d5514460068d12dc606d40b8aaaf3b
Author: LI,JHE-CHEN <[email protected]>
AuthorDate: Mon Aug 25 11:58:28 2025 -0400
Add Calendar view for Dag (#54252)
* feat(ui): WIP - Add initial scaffold for DAG Calendar view
feat(ui): Add Github style grid for Calendar tab
fix(ui): Fix time format for importing dag runs to calendar
* feat(ui): enhance Dag Calendar with hourly/daily view
* feat(ui): Implement state and Success Rate Spectrum for calendar view
* feat(ui): implement debounced tooltip for calendar grid
* feat(ui): enhance calendar view UX with improved loading and button
* feat(ui): modify Success Rate Spectrum layout in hourly calender view
* perf(ui): optimize calendar data processing
* feat(ui): refactor calendar legend layout and improve hourly view
positioning
* feat(ui): improve calendar cellSize controls and hourly view text display
* feat(ui): enhance calendar dark mode support and replace hardcoded RGB
colors
* refactor(ui): optimize calendar date state management
* refactor(ui): Extract calendar tooltip logic into useDelayedTooltip hook
* refactor(ui): reduce duplication in CalendarLegend
* style(ui): improve calendar UI consistency and accessibility
* fix(i18n): modify translations
* fix(ui): remove cellsize changing function
* feat(ui): GitHub-style heatmap, default to hourly.
* fix(ui): Rename calendar colorMode to viewMode to avoid confusion with
theme settings
* style(calendar): reduce cell size to 14x14px and simplify weekday labels
to single letters
* refactor(ui): consolidate calendar cells
* style(ui): modify calendar cell gap and legend size
---
.../airflow/ui/public/i18n/locales/en/common.json | 1 +
.../src/airflow/ui/public/i18n/locales/en/dag.json | 28 +++
.../ui/public/i18n/locales/zh-TW/common.json | 1 +
.../airflow/ui/public/i18n/locales/zh-TW/dag.json | 29 +++
.../airflow/ui/src/pages/Dag/Calendar/Calendar.tsx | 270 +++++++++++++++++++++
.../ui/src/pages/Dag/Calendar/CalendarCell.tsx | 50 ++++
.../ui/src/pages/Dag/Calendar/CalendarLegend.tsx | 113 +++++++++
.../ui/src/pages/Dag/Calendar/CalendarTooltip.tsx | 69 ++++++
.../src/pages/Dag/Calendar/DailyCalendarView.tsx | 127 ++++++++++
.../src/pages/Dag/Calendar/HourlyCalendarView.tsx | 193 +++++++++++++++
.../ui/src/pages/Dag/Calendar/calendarUtils.ts | 234 ++++++++++++++++++
.../src/airflow/ui/src/pages/Dag/Calendar/index.ts | 25 ++
.../src/airflow/ui/src/pages/Dag/Calendar/types.ts | 58 +++++
.../ui/src/pages/Dag/Calendar/useDelayedTooltip.ts | 60 +++++
airflow-core/src/airflow/ui/src/pages/Dag/Dag.tsx | 3 +-
airflow-core/src/airflow/ui/src/router.tsx | 2 +
airflow-core/src/airflow/ui/src/theme.ts | 4 +-
.../react_plugin_template/src/theme.ts | 4 +-
18 files changed, 1266 insertions(+), 5 deletions(-)
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 1e9e271b3c8..ca11ac93b27 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
@@ -181,6 +181,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 fc8463c169f..5a66838365d 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",
@@ -102,6 +129,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 04f3c39d653..85fceadb8f6 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
@@ -181,6 +181,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 8e9f4fb6e69..946efc199f7 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": "找不到程式碼",
@@ -102,6 +130,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 00000000000..ec72c813ab1
--- /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 (
+ <Box p={4}>
+ <Text>{translate("calendar.noData")}</Text>
+ </Box>
+ );
+ }
+
+ return (
+ <Box p={6}>
+ <ErrorAlert error={error} />
+ <HStack justify="space-between" mb={6}>
+ <HStack gap={4} mb={4}>
+ {granularity === "daily" ? (
+ <HStack gap={2}>
+ <IconButton
+ aria-label={translate("calendar.navigation.previousYear")}
+ onClick={() => setSelectedDate(selectedDate.subtract(1,
"year"))}
+ size="sm"
+ variant="ghost"
+ >
+ <FiChevronLeft />
+ </IconButton>
+ <Text
+ _hover={selectedDate.year() === currentDate.year() ? {} : {
textDecoration: "underline" }}
+ color={selectedDate.year() === currentDate.year() ? "fg.info"
: "inherit"}
+ cursor={selectedDate.year() === currentDate.year() ? "default"
: "pointer"}
+ fontSize="xl"
+ fontWeight="bold"
+ minWidth="120px"
+ onClick={() => {
+ if (selectedDate.year() !== currentDate.year()) {
+ setSelectedDate(currentDate.startOf("year"));
+ }
+ }}
+ textAlign="center"
+ >
+ {selectedDate.year()}
+ </Text>
+ <IconButton
+ aria-label={translate("calendar.navigation.nextYear")}
+ onClick={() => setSelectedDate(selectedDate.add(1, "year"))}
+ size="sm"
+ variant="ghost"
+ >
+ <FiChevronRight />
+ </IconButton>
+ </HStack>
+ ) : (
+ <HStack gap={2}>
+ <IconButton
+ aria-label={translate("calendar.navigation.previousMonth")}
+ onClick={() => setSelectedDate(selectedDate.subtract(1,
"month"))}
+ size="sm"
+ variant="ghost"
+ >
+ <FiChevronLeft />
+ </IconButton>
+ <Text
+ _hover={
+ selectedDate.isSame(currentDate, "month") &&
selectedDate.isSame(currentDate, "year")
+ ? {}
+ : { textDecoration: "underline" }
+ }
+ color={
+ selectedDate.isSame(currentDate, "month") &&
selectedDate.isSame(currentDate, "year")
+ ? "fg.info"
+ : "inherit"
+ }
+ cursor={
+ selectedDate.isSame(currentDate, "month") &&
selectedDate.isSame(currentDate, "year")
+ ? "default"
+ : "pointer"
+ }
+ fontSize="xl"
+ fontWeight="bold"
+ minWidth="120px"
+ onClick={() => {
+ if (
+ !(selectedDate.isSame(currentDate, "month") &&
selectedDate.isSame(currentDate, "year"))
+ ) {
+ setSelectedDate(currentDate.startOf("month"));
+ }
+ }}
+ textAlign="center"
+ >
+ {selectedDate.format("MMM YYYY")}
+ </Text>
+ <IconButton
+ aria-label={translate("calendar.navigation.nextMonth")}
+ onClick={() => setSelectedDate(selectedDate.add(1, "month"))}
+ size="sm"
+ variant="ghost"
+ >
+ <FiChevronRight />
+ </IconButton>
+ </HStack>
+ )}
+
+ <ButtonGroup attached size="sm" variant="outline">
+ <Button
+ colorPalette="blue"
+ onClick={() => setGranularity("daily")}
+ variant={granularity === "daily" ? "solid" : "outline"}
+ >
+ {translate("calendar.daily")}
+ </Button>
+ <Button
+ colorPalette="blue"
+ onClick={() => setGranularity("hourly")}
+ variant={granularity === "hourly" ? "solid" : "outline"}
+ >
+ {translate("calendar.hourly")}
+ </Button>
+ </ButtonGroup>
+
+ <ButtonGroup attached size="sm" variant="outline">
+ <Button
+ colorPalette="blue"
+ onClick={() => setViewMode("total")}
+ variant={viewMode === "total" ? "solid" : "outline"}
+ >
+ {translate("calendar.totalRuns")}
+ </Button>
+ <Button
+ colorPalette="blue"
+ onClick={() => setViewMode("failed")}
+ variant={viewMode === "failed" ? "solid" : "outline"}
+ >
+ {translate("overview.buttons.failedRun_other")}
+ </Button>
+ </ButtonGroup>
+ </HStack>
+ </HStack>
+
+ <Box position="relative">
+ {isLoading ? (
+ <Box
+ alignItems="center"
+ backdropFilter="blur(2px)"
+ bg="bg/80"
+ borderRadius="md"
+ bottom="0"
+ display="flex"
+ justifyContent="center"
+ left="0"
+ position="absolute"
+ right="0"
+ top="0"
+ zIndex={10}
+ >
+ <Box textAlign="center">
+ <Box
+ animation={`${spin} 1s linear infinite`}
+ border="3px solid"
+ borderColor={{ _dark: "gray.600", _light: "blue.100" }}
+ borderRadius="50%"
+ borderTopColor="blue.500"
+ height="24px"
+ width="24px"
+ />
+ </Box>
+ </Box>
+ ) : undefined}
+ {granularity === "daily" ? (
+ <>
+ <DailyCalendarView
+ colorMode={viewMode}
+ data={data?.dag_runs ?? []}
+ selectedYear={selectedDate.year()}
+ />
+ <CalendarLegend colorMode={viewMode} />
+ </>
+ ) : (
+ <HStack align="start" gap={2}>
+ <Box>
+ <HourlyCalendarView
+ colorMode={viewMode}
+ data={data?.dag_runs ?? []}
+ selectedMonth={selectedDate.month()}
+ selectedYear={selectedDate.year()}
+ />
+ </Box>
+ <Box display="flex" flex="1" justifyContent="center" pt={16}>
+ <CalendarLegend colorMode={viewMode} vertical />
+ </Box>
+ </HStack>
+ )}
+ </Box>
+ </Box>
+ );
+};
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 00000000000..d10864fc15e
--- /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, string> | 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 (
+ <Box onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}
position="relative">
+ <Box
+ _hover={{ transform: "scale(1.1)" }}
+ bg={backgroundColor}
+ borderRadius="2px"
+ cursor="pointer"
+ height="14px"
+ marginRight={computedMarginRight}
+ width="14px"
+ />
+ <CalendarTooltip content={content} />
+ </Box>
+ );
+};
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 00000000000..d66ba21bb6f
--- /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 (
+ <Box>
+ <Box mb={4}>
+ <Text color="fg.muted" fontSize="sm" fontWeight="medium" mb={3}
textAlign="center">
+ {legendTitle}
+ </Text>
+ {vertical ? (
+ <VStack align="center" gap={2}>
+ <Text color="fg.muted" fontSize="xs">
+ {translate("calendar.legend.more")}
+ </Text>
+ <VStack gap={0.5}>
+ {[...legendData].reverse().map(({ color, label }) => (
+ <Tooltip content={`${label} ${colorMode === "total" ? "runs" :
"failed"}`} key={label}>
+ <Box bg={color} borderRadius="2px" cursor="pointer"
height="14px" width="14px" />
+ </Tooltip>
+ ))}
+ </VStack>
+ <Text color="fg.muted" fontSize="xs">
+ {translate("calendar.legend.less")}
+ </Text>
+ </VStack>
+ ) : (
+ <HStack align="center" gap={2} justify="center">
+ <Text color="fg.muted" fontSize="xs">
+ {translate("calendar.legend.less")}
+ </Text>
+ <HStack gap={0.5}>
+ {legendData.map(({ color, label }) => (
+ <Tooltip content={`${label} ${colorMode === "total" ? "runs" :
"failed"}`} key={label}>
+ <Box bg={color} borderRadius="2px" cursor="pointer"
height="14px" width="14px" />
+ </Tooltip>
+ ))}
+ </HStack>
+ <Text color="fg.muted" fontSize="xs">
+ {translate("calendar.legend.more")}
+ </Text>
+ </HStack>
+ )}
+ </Box>
+
+ <Box>
+ <HStack gap={4} justify="center" wrap="wrap">
+ <HStack gap={2}>
+ <Box
+ bg={{ _dark: "scheduled.600", _light: "scheduled.200" }}
+ borderRadius="2px"
+ boxShadow="sm"
+ height="14px"
+ width="14px"
+ />
+ <Text color="fg.muted" fontSize="xs">
+ {translate("common:states.planned")}
+ </Text>
+ </HStack>
+ </HStack>
+ </Box>
+ </Box>
+ );
+};
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 00000000000..16559614210
--- /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 (
+ <div data-tooltip style={tooltipStyle}>
+ <div style={arrowStyle} />
+ {content}
+ </div>
+ );
+};
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 00000000000..a0854b686fa
--- /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<CalendarTimeRangeResponse>;
+ 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 (
+ <Box mb={4}>
+ <Box display="flex" mb={2}>
+ <Box width="30px" />
+ <Box display="flex" gap={0.5}>
+ {dailyData.map((week, index) => (
+ <Box key={`month-${week[0]?.date ?? index}`} position="relative"
width="14px">
+ {Boolean(week[0] && dayjs(week[0].date).date() <= 7) && (
+ <Text color="fg.muted" fontSize="2xs" left="0"
position="absolute" top="-20px">
+ {dayjs(week[0]?.date).format("MMM")}
+ </Text>
+ )}
+ </Box>
+ ))}
+ </Box>
+ </Box>
+ <Box display="flex" gap={2}>
+ <Box display="flex" flexDirection="column" gap={0.5}>
+ {weekdays.map((day) => (
+ <Box
+ alignItems="center"
+ color="fg.muted"
+ display="flex"
+ fontSize="2xs"
+ height="14px"
+ justifyContent="flex-end"
+ key={day}
+ pr={2}
+ width="20px"
+ >
+ {day}
+ </Box>
+ ))}
+ </Box>
+ <Box display="flex" gap={0.5}>
+ {dailyData.map((week, weekIndex) => (
+ <Box display="flex" flexDirection="column" gap={0.5}
key={`week-${week[0]?.date ?? weekIndex}`}>
+ {week.map((day) => {
+ const dayDate = dayjs(day.date);
+ const isInSelectedYear = dayDate.year() === selectedYear;
+
+ if (!isInSelectedYear) {
+ return <CalendarCell backgroundColor="transparent"
content="" key={day.date} />;
+ }
+
+ return (
+ <CalendarCell
+ backgroundColor={getCalendarCellColor(day.runs, colorMode)}
+ content={createTooltipContent(day)}
+ key={day.date}
+ />
+ );
+ })}
+ </Box>
+ ))}
+ </Box>
+ </Box>
+ </Box>
+ );
+};
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 00000000000..b4a9e5c1520
--- /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<CalendarTimeRangeResponse>;
+ 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 (
+ <Box mb={4}>
+ <Box mb={4}>
+ <Box display="flex" mb={2}>
+ <Box width="40px" />
+ <Box display="flex" gap={0.5}>
+ {hourlyData.days.map((day, index) => {
+ const isFirstOfWeek = index % 7 === 0;
+ const weekNumber = Math.floor(index / 7) + 1;
+
+ return (
+ <Box
+ key={day.day}
+ marginRight={index % 7 === 6 ? "8px" : "0"}
+ position="relative"
+ width="14px"
+ >
+ {Boolean(isFirstOfWeek) && (
+ <Text
+ color="fg.muted"
+ fontSize="xs"
+ fontWeight="bold"
+ left="0"
+ position="absolute"
+ textAlign="left"
+ top="-25px"
+ whiteSpace="nowrap"
+ >
+ {translate("calendar.week", { weekNumber })}
+ </Text>
+ )}
+ </Box>
+ );
+ })}
+ </Box>
+ </Box>
+ <Box display="flex" mb={1}>
+ <Box width="40px" />
+ <Box display="flex" gap={0.5}>
+ {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 (
+ <Box key={day.day} marginRight={index % 7 === 6 ? "8px" : "0"}
width="14px">
+ <Text
+ color={isWeekend ? "red.400" : "gray.600"}
+ fontSize={dateFontSize}
+ fontWeight={isWeekend ? "bold" : "normal"}
+ lineHeight="1"
+ textAlign="center"
+ >
+ {dayjs(day.day).format("D")}
+ </Text>
+ <Text
+ color={isWeekend ? "red.400" : "gray.500"}
+ fontSize={dayNameFontSize}
+ fontWeight={isWeekend ? "bold" : "normal"}
+ lineHeight="1"
+ mt="1px"
+ textAlign="center"
+ >
+ {dayName}
+ </Text>
+ </Box>
+ );
+ })}
+ </Box>
+ </Box>
+ </Box>
+
+ <Box display="flex" gap={2}>
+ <Box display="flex" flexDirection="column" gap={0.5}>
+ {Array.from({ length: 24 }, (_, hour) => (
+ <Box
+ alignItems="center"
+ color="gray.500"
+ display="flex"
+ fontSize="xs"
+ height="14px"
+ justifyContent="flex-end"
+ key={hour}
+ pr={2}
+ width="30px"
+ >
+ {hour % 4 === 0 && hour.toString().padStart(2, "0")}
+ </Box>
+ ))}
+ </Box>
+ <Box display="flex" flexDirection="column" gap={0.5}>
+ {Array.from({ length: 24 }, (_, hour) => (
+ <Box display="flex" gap={0.5} key={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 (
+ <CalendarCell
+ backgroundColor={getCalendarCellColor([], colorMode)}
+ content={noRunsTooltip}
+ index={index}
+ key={`${day.day}-${hour}`}
+ />
+ );
+ }
+
+ 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 (
+ <CalendarCell
+ backgroundColor={getCalendarCellColor(hourData.runs,
colorMode)}
+ content={tooltipContent}
+ index={index}
+ key={`${day.day}-${hour}`}
+ />
+ );
+ })}
+ </Box>
+ ))}
+ </Box>
+ </Box>
+ </Box>
+ );
+};
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 00000000000..52b1cba570f
--- /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<CalendarTimeRangeResponse>) => {
+ const dailyDataMap = new Map<string, Array<CalendarTimeRangeResponse>>();
+
+ 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<CalendarTimeRangeResponse>) => {
+ const hourlyDataMap = new Map<string, Array<CalendarTimeRangeResponse>>();
+
+ 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<CalendarTimeRangeResponse>):
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<CalendarTimeRangeResponse>,
+ 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<CalendarTimeRangeResponse>,
+ 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<CalendarTimeRangeResponse>,
+ 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 00000000000..85ffa1abfc2
--- /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 00000000000..832e71f1e27
--- /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<CalendarTimeRangeResponse>;
+};
+
+export type DayData = CalendarCellData;
+
+export type HourData = {
+ readonly hour: number;
+} & CalendarCellData;
+
+export type WeekData = Array<DayData>;
+
+export type DailyCalendarData = Array<WeekData>;
+
+export type HourlyCalendarData = {
+ readonly days: Array<{
+ readonly day: string;
+ readonly hours: Array<HourData>;
+ }>;
+ 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 00000000000..b2e0d17c0ea
--- /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<NodeJS.Timeout | undefined>(undefined);
+ const activeTooltipRef = useRef<HTMLElement | undefined>(undefined);
+
+ const handleMouseEnter = (event: React.MouseEvent<HTMLDivElement>) => {
+ 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 ae7e7bceea3..063841c4510 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: <LuChartColumn />, label: translate("tabs.overview"), value: "" },
{ icon: <FiBarChart />, label: translate("tabs.runs"), value: "runs" },
{ icon: <TaskIcon />, label: translate("tabs.tasks"), value: "tasks" },
+ { icon: <FiCalendar />, label: translate("tabs.calendar"), value:
"calendar" },
{ icon: <FiUser />, label: translate("tabs.requiredActions"), value:
"required_actions" },
{ icon: <RiArrowGoBackFill />, label: translate("tabs.backfills"), value:
"backfills" },
{ icon: <MdOutlineEventNote />, 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 07ac8b7dd56..093ea1b45bb 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: <Overview />, index: true },
{ element: <DagRuns />, path: "runs" },
{ element: <Tasks />, path: "tasks" },
+ { element: <Calendar />, path: "calendar" },
{ element: <HITLTaskInstances />, path: "required_actions" },
{ element: <Backfills />, path: "backfills" },
{ element: <Events />, path: "events" },
diff --git a/airflow-core/src/airflow/ui/src/theme.ts
b/airflow-core/src/airflow/ui/src/theme.ts
index 706ae0ed57e..88bac3fd192 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 706ae0ed57e..88bac3fd192 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" },