From 54e776f7e186843cf84b5dd331b1d18b0fe1f875 Mon Sep 17 00:00:00 2001
From: hojeong park <parkhj062@gmail.com>
Date: Wed, 13 May 2026 23:44:23 +0900
Subject: [PATCH] [v3-2-test] UI: Change queued Dag runs color to grey in
 Calendar (#66623)

* UI: Add tests for Calendar run color utilities

* UI: Add queued run color tests for Calendar

* UI: Show queued Calendar runs as planned

* UI: Refactor Calendar run count helpers
(cherry picked from commit 4f9174e554f95d517a4ded340336ab56f92227df)

Co-authored-by: hojeong park <parkhj062@gmail.com>
---
 .../pages/Dag/Calendar/calendarUtils.test.ts  | 186 ++++++++++++++++++
 .../src/pages/Dag/Calendar/calendarUtils.ts   |  37 +++-
 2 files changed, 214 insertions(+), 9 deletions(-)
 create mode 100644 airflow-core/src/airflow/ui/src/pages/Dag/Calendar/calendarUtils.test.ts

diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/calendarUtils.test.ts b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/calendarUtils.test.ts
new file mode 100644
index 0000000000000..1dec158028357
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/calendarUtils.test.ts
@@ -0,0 +1,186 @@
+/*!
+ * 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 { describe, expect, it } from "vitest";
+
+import type { CalendarTimeRangeResponse } from "openapi/requests/types.gen";
+
+import { calculateDataBounds, calculateRunCounts, createCalendarScale } from "./calendarUtils";
+import type { RunCounts } from "./types";
+
+const EMPTY_COLOR = { _dark: "gray.700", _light: "gray.100" };
+const PLANNED_COLOR = { _dark: "stone.600", _light: "stone.500" };
+const DEFAULT_TOTAL_COLOR = { _dark: "green.700", _light: "green.400" };
+const DEFAULT_FAILED_COLOR = { _dark: "red.700", _light: "red.400" };
+
+const EMPTY_COUNTS: RunCounts = {
+  failed: 0,
+  planned: 0,
+  queued: 0,
+  running: 0,
+  success: 0,
+  total: 0,
+};
+
+const run = (
+  state: CalendarTimeRangeResponse["state"],
+  count: number,
+  date = "2026-04-08T10:00:00Z",
+): CalendarTimeRangeResponse => ({
+  count,
+  date,
+  state,
+});
+
+describe("calculateRunCounts", () => {
+  it("counts each calendar state and includes all states in total", () => {
+    expect(
+      calculateRunCounts([
+        run("success", 2),
+        run("failed", 1),
+        run("running", 3),
+        run("queued", 4),
+        run("planned", 5),
+      ]),
+    ).toEqual({
+      failed: 1,
+      planned: 5,
+      queued: 4,
+      running: 3,
+      success: 2,
+      total: 15,
+    });
+  });
+});
+
+describe("calculateDataBounds", () => {
+  it("uses total counts for total mode bounds", () => {
+    expect(
+      calculateDataBounds(
+        [
+          run("success", 2, "2026-04-08T10:00:00Z"),
+          run("failed", 1, "2026-04-08T10:00:00Z"),
+          run("running", 4, "2026-04-08T11:00:00Z"),
+        ],
+        "total",
+        "hourly",
+      ),
+    ).toEqual({ maxCount: 4, minCount: 3 });
+  });
+
+  it("excludes queued runs from total mode bounds when actual runs are present", () => {
+    expect(
+      calculateDataBounds(
+        [run("queued", 100, "2026-04-08T10:00:00Z"), run("success", 1, "2026-04-08T11:00:00Z")],
+        "total",
+        "hourly",
+      ),
+    ).toEqual({ maxCount: 1, minCount: 1 });
+  });
+
+  it("keeps queued-only total mode data from using an empty scale", () => {
+    expect(calculateDataBounds([run("queued", 100)], "total", "hourly")).toEqual({
+      maxCount: 100,
+      minCount: 100,
+    });
+  });
+
+  it("uses failed counts for failed mode bounds", () => {
+    expect(
+      calculateDataBounds(
+        [
+          run("success", 10, "2026-04-08T10:00:00Z"),
+          run("failed", 2, "2026-04-08T10:00:00Z"),
+          run("failed", 5, "2026-04-08T11:00:00Z"),
+          run("queued", 20, "2026-04-08T11:00:00Z"),
+        ],
+        "failed",
+        "hourly",
+      ),
+    ).toEqual({ maxCount: 5, minCount: 2 });
+  });
+});
+
+describe("createCalendarScale", () => {
+  it("returns the planned color for a planned-only cell", () => {
+    const scale = createCalendarScale([run("planned", 1)], "total", "hourly");
+
+    expect(scale.getColor({ ...EMPTY_COUNTS, planned: 1, total: 1 })).toEqual(PLANNED_COLOR);
+  });
+
+  it("returns the default total color for a success-only cell", () => {
+    const scale = createCalendarScale([run("success", 1)], "total", "hourly");
+
+    expect(scale.getColor({ ...EMPTY_COUNTS, success: 1, total: 1 })).toEqual(DEFAULT_TOTAL_COLOR);
+  });
+
+  it("returns the planned color for a queued-only cell in total mode", () => {
+    const scale = createCalendarScale([run("queued", 1)], "total", "hourly");
+
+    expect(scale.getColor({ ...EMPTY_COUNTS, queued: 1, total: 1 })).toEqual(PLANNED_COLOR);
+  });
+
+  it("returns a mixed color for planned and actual runs in total mode", () => {
+    const scale = createCalendarScale([run("planned", 1), run("success", 1)], "total", "hourly");
+
+    expect(scale.getColor({ ...EMPTY_COUNTS, planned: 1, success: 1, total: 2 })).toEqual({
+      actual: DEFAULT_TOTAL_COLOR,
+      planned: PLANNED_COLOR,
+    });
+  });
+
+  it("returns a mixed color for queued and actual runs in total mode", () => {
+    const scale = createCalendarScale([run("queued", 1), run("success", 1)], "total", "hourly");
+
+    expect(scale.getColor({ ...EMPTY_COUNTS, queued: 1, success: 1, total: 2 })).toEqual({
+      actual: DEFAULT_TOTAL_COLOR,
+      planned: PLANNED_COLOR,
+    });
+  });
+
+  it("uses failed counts for failed mode", () => {
+    const scale = createCalendarScale([run("success", 5), run("failed", 1)], "failed", "hourly");
+
+    expect(scale.getColor({ ...EMPTY_COUNTS, success: 5, total: 5 })).toEqual(EMPTY_COLOR);
+    expect(scale.getColor({ ...EMPTY_COUNTS, failed: 1, total: 1 })).toEqual(DEFAULT_FAILED_COLOR);
+  });
+
+  it("returns a mixed color for planned and failed runs in failed mode", () => {
+    const scale = createCalendarScale([run("planned", 1), run("failed", 1)], "failed", "hourly");
+
+    expect(scale.getColor({ ...EMPTY_COUNTS, failed: 1, planned: 1, total: 2 })).toEqual({
+      actual: DEFAULT_FAILED_COLOR,
+      planned: PLANNED_COLOR,
+    });
+  });
+
+  it("returns the planned color for a queued-only cell in failed mode", () => {
+    const scale = createCalendarScale([run("queued", 1)], "failed", "hourly");
+
+    expect(scale.getColor({ ...EMPTY_COUNTS, queued: 1, total: 1 })).toEqual(PLANNED_COLOR);
+  });
+
+  it("returns a mixed color for queued and failed runs in failed mode", () => {
+    const scale = createCalendarScale([run("queued", 1), run("failed", 1)], "failed", "hourly");
+
+    expect(scale.getColor({ ...EMPTY_COUNTS, failed: 1, queued: 1, total: 2 })).toEqual({
+      actual: DEFAULT_FAILED_COLOR,
+      planned: PLANNED_COLOR,
+    });
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/calendarUtils.ts b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/calendarUtils.ts
index 3d4fbaab8465b..712699f57074d 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/calendarUtils.ts
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/calendarUtils.ts
@@ -55,6 +55,11 @@ const FAILURE_COLOR_INTENSITIES = [
   { _dark: "red.300", _light: "red.800" },
 ];
 
+const getActualRunCount = (counts: RunCounts, viewMode: CalendarColorMode) =>
+  viewMode === "total" ? counts.total - counts.planned - counts.queued : counts.failed;
+
+const getPendingRunCount = (counts: RunCounts) => counts.planned + counts.queued;
+
 const createDailyDataMap = (data: Array<CalendarTimeRangeResponse>) => {
   const dailyDataMap = new Map<string, Array<CalendarTimeRangeResponse>>();
 
@@ -181,19 +186,33 @@ export const calculateDataBounds = (
   }
 
   const counts: Array<number> = [];
+  const pendingCounts: Array<number> = [];
   const mapCreator = granularity === "daily" ? createDailyDataMap : createHourlyDataMap;
   const dataMap = mapCreator(data);
 
   dataMap.forEach((runs) => {
     const runCounts = calculateRunCounts(runs);
-    const targetCount = viewMode === "total" ? runCounts.total : runCounts.failed;
+    const targetCount = getActualRunCount(runCounts, viewMode);
 
     if (targetCount > 0) {
       counts.push(targetCount);
+    } else {
+      const pendingCount = getPendingRunCount(runCounts);
+
+      if (pendingCount > 0) {
+        pendingCounts.push(pendingCount);
+      }
     }
   });
 
   if (counts.length === 0) {
+    if (pendingCounts.length > 0) {
+      return {
+        maxCount: Math.max(...pendingCounts),
+        minCount: Math.min(...pendingCounts),
+      };
+    }
+
     return { maxCount: 0, minCount: 0 };
   }
 
@@ -226,18 +245,18 @@ export const createCalendarScale = (
 
     return {
       getColor: (counts: RunCounts) => {
-        const actualCount = viewMode === "total" ? counts.total - counts.planned : counts.failed;
-        const hasPlanned = counts.planned > 0;
+        const actualCount = getActualRunCount(counts, viewMode);
+        const hasPending = getPendingRunCount(counts) > 0;
         const hasActual = actualCount > 0;
 
-        if (hasPlanned && hasActual) {
+        if (hasPending && hasActual) {
           return {
             actual: singleColor,
             planned: PLANNED_COLOR,
           };
         }
 
-        if (hasPlanned && !hasActual) {
+        if (hasPending && !hasActual) {
           return PLANNED_COLOR;
         }
 
@@ -274,11 +293,11 @@ export const createCalendarScale = (
         actual: string | { _dark: string; _light: string };
         planned: string | { _dark: string; _light: string };
       } => {
-    const actualCount = viewMode === "total" ? counts.total - counts.planned : counts.failed;
-    const hasPlanned = counts.planned > 0;
+    const actualCount = getActualRunCount(counts, viewMode);
+    const hasPending = getPendingRunCount(counts) > 0;
     const hasActual = actualCount > 0;
 
-    if (hasPlanned && hasActual) {
+    if (hasPending && hasActual) {
       let actualColor = colorScheme[0] ?? EMPTY_COLOR;
 
       for (let index = uniqueThresholds.length - 1; index >= 1; index -= 1) {
@@ -300,7 +319,7 @@ export const createCalendarScale = (
       };
     }
 
-    if (hasPlanned && !hasActual) {
+    if (hasPending && !hasActual) {
       return PLANNED_COLOR;
     }
 
