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" },

Reply via email to