Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions airflow-core/src/airflow/ui/src/pages/Dag/Calendar/Calendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
import { Box, HStack, Text } from "@chakra-ui/react";
import { keyframes } from "@emotion/react";
import dayjs from "dayjs";
import tz from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { FiChevronLeft, FiChevronRight } from "react-icons/fi";
Expand All @@ -30,6 +32,7 @@ import { ErrorAlert } from "src/components/ErrorAlert";
import { IconButton } from "src/components/ui";
import { ButtonGroupToggle } from "src/components/ui/ButtonGroupToggle";
import { CALENDAR_GRANULARITY_KEY, CALENDAR_VIEW_MODE_KEY } from "src/constants/localStorage";
import { useTimezone } from "src/context/timezone";

import { CalendarLegend } from "./CalendarLegend";
import { DailyCalendarView } from "./DailyCalendarView";
Expand All @@ -41,6 +44,9 @@ const spin = keyframes`
to { transform: rotate(360deg); }
`;

dayjs.extend(utc);
dayjs.extend(tz);

export const Calendar = () => {
const { dagId = "" } = useParams();
const { t: translate } = useTranslation("dag");
Expand All @@ -53,14 +59,20 @@ export const Calendar = () => {

const currentDate = dayjs();

const { selectedTimezone } = useTimezone();

const { data: dag } = useDagServiceGetDagDetails({ dagId });
const isPartitioned = dag?.timetable_partitioned ?? false;

const startDate = granularity === "daily" ? selectedDate.startOf("year") : selectedDate.startOf("month");
const endDate = granularity === "daily" ? selectedDate.endOf("year") : selectedDate.endOf("month");
// Compute the date range in the selected timezone, then convert to UTC for API
const tzDate = selectedDate.tz(selectedTimezone, true);
const startDate =
granularity === "daily" ? tzDate.startOf("year") : tzDate.startOf("month");
const endDate =
granularity === "daily" ? tzDate.endOf("year") : tzDate.endOf("month");

const gte = startDate.format("YYYY-MM-DD[T]HH:mm:ss[Z]");
const lte = endDate.format("YYYY-MM-DD[T]HH:mm:ss[Z]");
const gte = startDate.utc().format("YYYY-MM-DDTHH:mm:ss.SSS[Z]");
const lte = endDate.utc().format("YYYY-MM-DDTHH:mm:ss.SSS[Z]");

const { data, error, isLoading } = useCalendarServiceGetCalendar(
{
Expand All @@ -74,7 +86,7 @@ export const Calendar = () => {
{ enabled: Boolean(dagId) },
);

const scale = createCalendarScale(data?.dag_runs ?? [], viewMode, granularity);
const scale = createCalendarScale(data?.dag_runs ?? [], viewMode, granularity, selectedTimezone);

if (!data && !isLoading) {
return (
Expand Down Expand Up @@ -226,6 +238,7 @@ export const Calendar = () => {
data-testid="calendar-daily-view"
scale={scale}
selectedYear={selectedDate.year()}
timezone={selectedTimezone}
viewMode={viewMode}
/>
<CalendarLegend scale={scale} viewMode={viewMode} />
Expand All @@ -238,6 +251,7 @@ export const Calendar = () => {
scale={scale}
selectedMonth={selectedDate.month()}
selectedYear={selectedDate.year()}
timezone={selectedTimezone}
viewMode={viewMode}
/>
</Box>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,13 @@ type Props = {
readonly data: Array<CalendarTimeRangeResponse>;
readonly scale: CalendarScale;
readonly selectedYear: number;
readonly timezone: string;
readonly viewMode?: CalendarColorMode;
};

export const DailyCalendarView = ({ data, scale, selectedYear, viewMode = "total" }: Props) => {
export const DailyCalendarView = ({ data, scale, selectedYear, timezone, viewMode = "total" }: Props) => {
const { t: translate } = useTranslation("dag");
const dailyData = generateDailyCalendarData(data, selectedYear);
const dailyData = generateDailyCalendarData(data, selectedYear, timezone);

const weekdays = [
translate("calendar.weekdays.sunday"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ type Props = {
readonly scale: CalendarScale;
readonly selectedMonth: number;
readonly selectedYear: number;
readonly timezone: string;
readonly viewMode?: CalendarColorMode;
};

Expand All @@ -61,10 +62,11 @@ export const HourlyCalendarView = ({
scale,
selectedMonth,
selectedYear,
timezone,
viewMode = "total",
}: Props) => {
const { t: translate } = useTranslation("dag");
const hourlyData = generateHourlyCalendarData(data, selectedYear, selectedMonth);
const hourlyData = generateHourlyCalendarData(data, selectedYear, selectedMonth, timezone);

return (
<Box data-testid="calendar-hourly-view" mb={4}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ describe("calculateDataBounds", () => {
],
"total",
"hourly",
"UTC",
),
).toEqual({ maxCount: 4, minCount: 3 });
});
Expand All @@ -89,12 +90,13 @@ describe("calculateDataBounds", () => {
[run("queued", 100, "2026-04-08T10:00:00Z"), run("success", 1, "2026-04-08T11:00:00Z")],
"total",
"hourly",
"UTC",
),
).toEqual({ maxCount: 1, minCount: 1 });
});

it("keeps queued-only total mode data from using an empty scale", () => {
expect(calculateDataBounds([run("queued", 100)], "total", "hourly")).toEqual({
expect(calculateDataBounds([run("queued", 100)], "total", "hourly", "UTC")).toEqual({
maxCount: 100,
minCount: 100,
});
Expand All @@ -111,32 +113,33 @@ describe("calculateDataBounds", () => {
],
"failed",
"hourly",
"UTC",
),
).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");
const scale = createCalendarScale([run("planned", 1)], "total", "hourly", "UTC");

expect(scale.getColor({ ...EMPTY_COUNTS, planned: 1, total: 1 })).toEqual(PLANNED_COLOR);
});

it("returns the default total color for a success-only cell", () => {
const scale = createCalendarScale([run("success", 1)], "total", "hourly");
const scale = createCalendarScale([run("success", 1)], "total", "hourly", "UTC");

expect(scale.getColor({ ...EMPTY_COUNTS, success: 1, total: 1 })).toEqual(DEFAULT_TOTAL_COLOR);
});

it("returns the planned color for a queued-only cell in total mode", () => {
const scale = createCalendarScale([run("queued", 1)], "total", "hourly");
const scale = createCalendarScale([run("queued", 1)], "total", "hourly", "UTC");

expect(scale.getColor({ ...EMPTY_COUNTS, queued: 1, total: 1 })).toEqual(PLANNED_COLOR);
});

it("returns a mixed color for planned and actual runs in total mode", () => {
const scale = createCalendarScale([run("planned", 1), run("success", 1)], "total", "hourly");
const scale = createCalendarScale([run("planned", 1), run("success", 1)], "total", "hourly", "UTC");

expect(scale.getColor({ ...EMPTY_COUNTS, planned: 1, success: 1, total: 2 })).toEqual({
actual: DEFAULT_TOTAL_COLOR,
Expand All @@ -145,7 +148,7 @@ describe("createCalendarScale", () => {
});

it("returns a mixed color for queued and actual runs in total mode", () => {
const scale = createCalendarScale([run("queued", 1), run("success", 1)], "total", "hourly");
const scale = createCalendarScale([run("queued", 1), run("success", 1)], "total", "hourly", "UTC");

expect(scale.getColor({ ...EMPTY_COUNTS, queued: 1, success: 1, total: 2 })).toEqual({
actual: DEFAULT_TOTAL_COLOR,
Expand All @@ -154,14 +157,14 @@ describe("createCalendarScale", () => {
});

it("uses failed counts for failed mode", () => {
const scale = createCalendarScale([run("success", 5), run("failed", 1)], "failed", "hourly");
const scale = createCalendarScale([run("success", 5), run("failed", 1)], "failed", "hourly", "UTC");

expect(scale.getColor({ ...EMPTY_COUNTS, success: 5, total: 5 })).toEqual(EMPTY_COLOR);
expect(scale.getColor({ ...EMPTY_COUNTS, failed: 1, total: 1 })).toEqual(DEFAULT_FAILED_COLOR);
});

it("returns a mixed color for planned and failed runs in failed mode", () => {
const scale = createCalendarScale([run("planned", 1), run("failed", 1)], "failed", "hourly");
const scale = createCalendarScale([run("planned", 1), run("failed", 1)], "failed", "hourly", "UTC");

expect(scale.getColor({ ...EMPTY_COUNTS, failed: 1, planned: 1, total: 2 })).toEqual({
actual: DEFAULT_FAILED_COLOR,
Expand All @@ -170,13 +173,13 @@ describe("createCalendarScale", () => {
});

it("returns the planned color for a queued-only cell in failed mode", () => {
const scale = createCalendarScale([run("queued", 1)], "failed", "hourly");
const scale = createCalendarScale([run("queued", 1)], "failed", "hourly", "UTC");

expect(scale.getColor({ ...EMPTY_COUNTS, queued: 1, total: 1 })).toEqual(PLANNED_COLOR);
});

it("returns a mixed color for queued and failed runs in failed mode", () => {
const scale = createCalendarScale([run("queued", 1), run("failed", 1)], "failed", "hourly");
const scale = createCalendarScale([run("queued", 1), run("failed", 1)], "failed", "hourly", "UTC");

expect(scale.getColor({ ...EMPTY_COUNTS, failed: 1, queued: 1, total: 2 })).toEqual({
actual: DEFAULT_FAILED_COLOR,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
*/
import dayjs from "dayjs";
import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
import tz from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";

import type { CalendarTimeRangeResponse } from "openapi/requests/types.gen";

Expand All @@ -32,6 +34,8 @@ import type {
} from "./types";

dayjs.extend(isSameOrBefore);
dayjs.extend(utc);
dayjs.extend(tz);

// Calendar color constants
export const PLANNED_COLOR = { _dark: "stone.600", _light: "stone.500" };
Expand All @@ -58,11 +62,11 @@ const getActualRunCount = (counts: RunCounts, viewMode: CalendarColorMode) =>

const getPendingRunCount = (counts: RunCounts) => counts.planned + counts.queued;

const createDailyDataMap = (data: Array<CalendarTimeRangeResponse>) => {
const createDailyDataMap = (data: Array<CalendarTimeRangeResponse>, timezone: string) => {
const dailyDataMap = new Map<string, Array<CalendarTimeRangeResponse>>();

data.forEach((run) => {
const dateStr = run.date.slice(0, 10); // "YYYY-MM-DD"
const dateStr = dayjs(run.date).tz(timezone).format("YYYY-MM-DD");
const dailyRuns = dailyDataMap.get(dateStr);

if (dailyRuns) {
Expand All @@ -75,11 +79,11 @@ const createDailyDataMap = (data: Array<CalendarTimeRangeResponse>) => {
return dailyDataMap;
};

const createHourlyDataMap = (data: Array<CalendarTimeRangeResponse>) => {
const createHourlyDataMap = (data: Array<CalendarTimeRangeResponse>, timezone: string) => {
const hourlyDataMap = new Map<string, Array<CalendarTimeRangeResponse>>();

data.forEach((run) => {
const hourStr = run.date.slice(0, 13); // "YYYY-MM-DDTHH"
const hourStr = dayjs(run.date).tz(timezone).format("YYYY-MM-DDTHH");
const hourlyRuns = hourlyDataMap.get(hourStr);

if (hourlyRuns) {
Expand Down Expand Up @@ -117,12 +121,13 @@ export const calculateRunCounts = (runs: Array<CalendarTimeRangeResponse>): RunC
export const generateDailyCalendarData = (
data: Array<CalendarTimeRangeResponse>,
selectedYear: number,
timezone: string,
): DailyCalendarData => {
const dailyDataMap = createDailyDataMap(data);
const dailyDataMap = createDailyDataMap(data, timezone);

const weeks = [];
const startOfYear = dayjs().year(selectedYear).startOf("year");
const endOfYear = dayjs().year(selectedYear).endOf("year");
const startOfYear = dayjs().tz(timezone).year(selectedYear).startOf("year");
const endOfYear = dayjs().tz(timezone).year(selectedYear).endOf("year");

let currentDate = startOfYear.startOf("week");
const endDate = endOfYear.endOf("week");
Expand All @@ -148,11 +153,12 @@ export const generateHourlyCalendarData = (
data: Array<CalendarTimeRangeResponse>,
selectedYear: number,
selectedMonth: number,
timezone: string,
): HourlyCalendarData => {
const hourlyDataMap = createHourlyDataMap(data);
const hourlyDataMap = createHourlyDataMap(data, timezone);

const monthStart = dayjs().year(selectedYear).month(selectedMonth).startOf("month");
const monthEnd = dayjs().year(selectedYear).month(selectedMonth).endOf("month");
const monthStart = dayjs().tz(timezone).year(selectedYear).month(selectedMonth).startOf("month");
const monthEnd = dayjs().tz(timezone).year(selectedYear).month(selectedMonth).endOf("month");
const monthData = [];

let currentDate = monthStart;
Expand All @@ -178,6 +184,7 @@ export const calculateDataBounds = (
data: Array<CalendarTimeRangeResponse>,
viewMode: CalendarColorMode,
granularity: CalendarGranularity,
timezone: string,
): { maxCount: number; minCount: number } => {
if (data.length === 0) {
return { maxCount: 0, minCount: 0 };
Expand All @@ -186,7 +193,7 @@ export const calculateDataBounds = (
const counts: Array<number> = [];
const pendingCounts: Array<number> = [];
const mapCreator = granularity === "daily" ? createDailyDataMap : createHourlyDataMap;
const dataMap = mapCreator(data);
const dataMap = mapCreator(data, timezone);

dataMap.forEach((runs) => {
const runCounts = calculateRunCounts(runs);
Expand Down Expand Up @@ -224,8 +231,9 @@ export const createCalendarScale = (
data: Array<CalendarTimeRangeResponse>,
viewMode: CalendarColorMode,
granularity: CalendarGranularity,
timezone: string,
): CalendarScale => {
const { maxCount, minCount } = calculateDataBounds(data, viewMode, granularity);
const { maxCount, minCount } = calculateDataBounds(data, viewMode, granularity, timezone);

// Handle empty data case
if (maxCount === 0) {
Expand Down
Loading