diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyFormLauncher.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyFormLauncher.tsx
index 3aeff75b514b2783d5485d60443d7ce302dd6dc6..2df04c6326355542aa8a48fbde599582eab1811e 100644
--- a/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyFormLauncher.tsx
+++ b/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyFormLauncher.tsx
@@ -80,7 +80,7 @@ export const StrategyFormLauncher = ({
       <Tooltip
         position="bottom"
         disabled={!inheritsRootStrategy}
-        label={t`Inheriting from default policy`}
+        label={t`Using default policy`}
       >
         <PolicyToken
           onClick={() => {
diff --git a/frontend/src/metabase-types/api/performance.ts b/frontend/src/metabase-types/api/performance.ts
index ffd7fab62c5aa8b24b5f0947b2ba177a277db187..735bc89a29b60f66a977647fff0b2ff8addc8eba 100644
--- a/frontend/src/metabase-types/api/performance.ts
+++ b/frontend/src/metabase-types/api/performance.ts
@@ -1,6 +1,11 @@
 type Model = "root" | "database" | "collection" | "dashboard" | "question";
 
-export type StrategyType = "nocache" | "ttl" | "duration" | "inherit";
+export type StrategyType =
+  | "nocache"
+  | "ttl"
+  | "duration"
+  | "schedule"
+  | "inherit";
 
 interface StrategyBase {
   type: StrategyType;
@@ -34,12 +39,18 @@ export interface InheritStrategy extends StrategyBase {
   type: "inherit";
 }
 
+export interface ScheduleStrategy extends StrategyBase {
+  type: "schedule";
+  schedule: string;
+}
+
 /** Cache invalidation strategy */
 export type Strategy =
   | DoNotCacheStrategy
   | TTLStrategy
   | DurationStrategy
-  | InheritStrategy;
+  | InheritStrategy
+  | ScheduleStrategy;
 
 /** Cache invalidation configuration */
 export interface Config {
diff --git a/frontend/src/metabase/admin/performance/components/StrategyForm.tsx b/frontend/src/metabase/admin/performance/components/StrategyForm.tsx
index d8982a84ac46ada566f9f19925ac5fa1e1b1cf8f..078a443efad434388844f466588920a04a803569 100644
--- a/frontend/src/metabase/admin/performance/components/StrategyForm.tsx
+++ b/frontend/src/metabase/admin/performance/components/StrategyForm.tsx
@@ -1,9 +1,11 @@
 import { useFormikContext } from "formik";
 import type { ReactNode } from "react";
-import { useEffect, useState } from "react";
+import { useCallback, useEffect, useMemo, useState } from "react";
 import { t } from "ttag";
 import _ from "underscore";
 
+import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper";
+import { Schedule } from "metabase/components/Schedule/Schedule";
 import type { FormTextInputProps } from "metabase/forms";
 import {
   Form,
@@ -14,6 +16,8 @@ import {
   useFormContext,
 } from "metabase/forms";
 import { color } from "metabase/lib/colors";
+import { useSelector } from "metabase/lib/redux";
+import { getSetting } from "metabase/selectors/settings";
 import {
   Button,
   Group,
@@ -25,11 +29,17 @@ import {
   Title,
   Tooltip,
 } from "metabase/ui";
-import type { Strategy, StrategyType } from "metabase-types/api";
+import type {
+  ScheduleSettings,
+  ScheduleStrategy,
+  Strategy,
+  StrategyType,
+} from "metabase-types/api";
 import { DurationUnit } from "metabase-types/api";
 
 import { useRecentlyTrue } from "../hooks/useRecentlyTrue";
 import { rootId, Strategies, strategyValidationSchema } from "../strategies";
+import { cronToScheduleSettings, scheduleSettingsToCron } from "../utils";
 
 export const StrategyForm = ({
   targetId,
@@ -127,12 +137,49 @@ const StrategyFormBody = ({
             <input type="hidden" name="unit" />
           </>
         )}
+        {selectedStrategyType === "schedule" && <ScheduleStrategyFormFields />}
       </Stack>
       <FormButtons />
     </Form>
   );
 };
 
+const ScheduleStrategyFormFields = () => {
+  const { values, setFieldValue } = useFormikContext<ScheduleStrategy>();
+  const { schedule: scheduleInCronFormat } = values;
+  const initialSchedule = cronToScheduleSettings(scheduleInCronFormat);
+  const [schedule, setSchedule] = useState<ScheduleSettings>(
+    initialSchedule || {},
+  );
+  const timezone = useSelector(state =>
+    getSetting(state, "report-timezone-short"),
+  );
+  const onScheduleChange = useCallback(
+    (nextSchedule: ScheduleSettings) => {
+      setSchedule(nextSchedule);
+      const cron = scheduleSettingsToCron(nextSchedule);
+      setFieldValue("schedule", cron);
+    },
+    [setFieldValue, setSchedule],
+  );
+  if (!initialSchedule) {
+    return (
+      <LoadingAndErrorWrapper
+        error={t`Error: Cannot interpret schedule: ${scheduleInCronFormat}`}
+      />
+    );
+  }
+  return (
+    <Schedule
+      schedule={schedule}
+      scheduleOptions={["hourly", "daily", "weekly", "monthly"]}
+      onScheduleChange={onScheduleChange}
+      verb={t`Invalidate`}
+      timezone={timezone}
+    />
+  );
+};
+
 export const FormButtons = () => {
   const { dirty } = useFormikContext<Strategy>();
   const { status } = useFormContext();
@@ -180,8 +227,9 @@ export const FormButtons = () => {
 const StrategySelector = ({ targetId }: { targetId: number | null }) => {
   const { values } = useFormikContext<Strategy>();
 
-  const availableStrategies =
-    targetId === rootId ? _.omit(Strategies, "inherit") : Strategies;
+  const availableStrategies = useMemo(() => {
+    return targetId === rootId ? _.omit(Strategies, "inherit") : Strategies;
+  }, [targetId]);
 
   return (
     <section>
diff --git a/frontend/src/metabase/admin/performance/strategies.ts b/frontend/src/metabase/admin/performance/strategies.ts
index 2bb3edf3beee453c73d1f1411b19b13ec7de5537..9300ad8bf5930cc33137735c0e3cb147d41771b1 100644
--- a/frontend/src/metabase/admin/performance/strategies.ts
+++ b/frontend/src/metabase/admin/performance/strategies.ts
@@ -53,6 +53,11 @@ export const durationStrategyValidationSchema = Yup.object({
   ),
 });
 
+export const scheduleStrategyValidationSchema = Yup.object({
+  type: Yup.string().equals(["schedule"]),
+  schedule: Yup.string().required(t`A cron expression is required`),
+});
+
 export const strategyValidationSchema = Yup.object().test(
   "strategy-validation",
   "The object must match one of the strategy validation schemas",
@@ -94,6 +99,11 @@ export const Strategies: Record<StrategyType, StrategyData> = {
     shortLabel: c("'TTL' is short for 'time-to-live'").t`TTL`,
     validateWith: ttlStrategyValidationSchema,
   },
+  schedule: {
+    label: t`Schedule: at regular intervals`,
+    shortLabel: t`Schedule`,
+    validateWith: scheduleStrategyValidationSchema,
+  },
   duration: {
     label: t`Duration: after a specific number of hours`,
     validateWith: durationStrategyValidationSchema,
diff --git a/frontend/src/metabase/admin/performance/utils.ts b/frontend/src/metabase/admin/performance/utils.ts
new file mode 100644
index 0000000000000000000000000000000000000000..950cc4347d4769b7783d0d934edfe1358da339cc
--- /dev/null
+++ b/frontend/src/metabase/admin/performance/utils.ts
@@ -0,0 +1,140 @@
+import { pick } from "underscore";
+
+import { Cron, weekdays } from "metabase/components/Schedule/constants";
+import type {
+  ScheduleDayType,
+  ScheduleFrameType,
+  ScheduleSettings,
+  ScheduleType,
+} from "metabase-types/api";
+
+const dayToCron = (day: ScheduleSettings["schedule_day"]) => {
+  const index = weekdays.findIndex(o => o.value === day);
+  if (index === -1) {
+    throw new Error(`Invalid day: ${day}`);
+  }
+  return index + 1;
+};
+
+const frameToCronMap = { first: "1", last: "L", mid: "15" };
+const frameToCron = (frame: ScheduleFrameType) => frameToCronMap[frame];
+
+const frameFromCronMap: Record<string, ScheduleFrameType> = {
+  "15": "mid",
+  "1": "first",
+  L: "last",
+};
+const frameFromCron = (frameInCronFormat: string) =>
+  frameFromCronMap[frameInCronFormat];
+
+export const scheduleSettingsToCron = (settings: ScheduleSettings): string => {
+  const second = "0";
+  const minute = settings.schedule_minute?.toString() ?? Cron.AllValues;
+  const hour = settings.schedule_hour?.toString() ?? Cron.AllValues;
+  let weekday = settings.schedule_day
+    ? dayToCron(settings.schedule_day).toString()
+    : Cron.NoSpecificValue;
+  const month = Cron.AllValues;
+  let dayOfMonth: string = settings.schedule_day
+    ? Cron.NoSpecificValue
+    : Cron.AllValues;
+  if (settings.schedule_type === "monthly" && settings.schedule_frame) {
+    // There are two kinds of monthly schedule:
+    // - weekday-based (e.g. "on the first Monday of the month")
+    // - date-based (e.g. "on the 15th of the month")
+    if (settings.schedule_day) {
+      // Handle weekday-based monthly schedule
+      const frameInCronFormat = frameToCron(settings.schedule_frame).replace(
+        /^1$/,
+        "#1",
+      );
+      const dayInCronFormat = dayToCron(settings.schedule_day);
+      weekday = `${dayInCronFormat}${frameInCronFormat}`;
+    } else {
+      // Handle date-based monthly schedule
+      dayOfMonth = frameToCron(settings.schedule_frame);
+    }
+  }
+  const cronExpression = [
+    second,
+    minute,
+    hour,
+    dayOfMonth,
+    month,
+    weekday,
+  ].join(" ");
+  return cronExpression;
+};
+
+/** Returns null if we can't convert the cron expression to a ScheduleSettings object */
+export const cronToScheduleSettings = (
+  cron: string | null | undefined,
+): ScheduleSettings | null => {
+  if (!cron) {
+    return defaultSchedule;
+  }
+
+  // The Quartz cron library used in the backend distinguishes between 'no specific value' and 'all values',
+  // but for simplicity we can treat them as the same here
+  cron = cron.replace(
+    new RegExp(Cron.NoSpecificValue_Escaped, "g"),
+    Cron.AllValues,
+  );
+
+  const [_second, minute, hour, dayOfMonth, month, weekday] = cron.split(" ");
+
+  if (month !== Cron.AllValues) {
+    return null;
+  }
+  let schedule_type: ScheduleType | undefined;
+  if (dayOfMonth === Cron.AllValues) {
+    if (weekday === Cron.AllValues) {
+      schedule_type = hour === Cron.AllValues ? "hourly" : "daily";
+    } else {
+      // If the weekday part of the cron expression is something like '1#1' (first Monday),
+      // or '2L' (last Tuesday), then the frequency is monthly
+      const oneWeekPerMonth = weekday.match(/[#L]/);
+      schedule_type = oneWeekPerMonth ? "monthly" : "weekly";
+    }
+  } else {
+    schedule_type = "monthly";
+  }
+  let schedule_frame: ScheduleFrameType | undefined;
+  let schedule_day: ScheduleDayType | undefined;
+  if (schedule_type === "monthly") {
+    if (weekday === Cron.AllValues) {
+      schedule_frame = frameFromCron(dayOfMonth);
+    } else {
+      // Split on transition from number to non-number
+      const weekdayParts = weekday.split(/(?<=\d)(?=\D)/);
+      const day = parseInt(weekdayParts[0]);
+      schedule_day = weekdays[day - 1]?.value as ScheduleDayType;
+      if (dayOfMonth === Cron.AllValues) {
+        const frameInCronFormat = weekdayParts[1].replace(/^#/, "");
+        schedule_frame = frameFromCron(frameInCronFormat);
+      } else {
+        schedule_frame = frameFromCron(dayOfMonth);
+      }
+    }
+  } else {
+    if (weekday !== Cron.AllValues) {
+      schedule_day = weekdays[parseInt(weekday) - 1]?.value as ScheduleDayType;
+    }
+  }
+  return {
+    schedule_type,
+    schedule_minute: parseInt(minute),
+    schedule_hour: parseInt(hour),
+    schedule_day,
+    schedule_frame,
+  };
+};
+
+const defaultSchedule: ScheduleSettings = {
+  schedule_type: "hourly",
+  schedule_minute: 0,
+};
+
+export const hourToTwelveHourFormat = (hour: number) => hour % 12 || 12;
+
+export const removeFalsyValues = (obj: any) => pick(obj, val => val);
diff --git a/frontend/src/metabase/admin/performance/utils.unit.spec.ts b/frontend/src/metabase/admin/performance/utils.unit.spec.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0e0d4beb306568091b1de8a992fd4b0c80e4ea2f
--- /dev/null
+++ b/frontend/src/metabase/admin/performance/utils.unit.spec.ts
@@ -0,0 +1,249 @@
+import type { ScheduleSettings } from "metabase-types/api";
+
+import {
+  cronToScheduleSettings,
+  hourToTwelveHourFormat,
+  removeFalsyValues,
+  scheduleSettingsToCron,
+} from "./utils";
+
+describe("scheduleSettingsToCron", () => {
+  it("converts hourly schedule to cron", () => {
+    const settings: ScheduleSettings = {
+      schedule_type: "hourly",
+      schedule_minute: 1,
+      schedule_hour: 1,
+    };
+    const cron = scheduleSettingsToCron(settings);
+    expect(cron).toEqual("0 1 1 * * ?");
+  });
+
+  it("converts daily schedule to cron", () => {
+    const settings: ScheduleSettings = {
+      schedule_type: "daily",
+      schedule_minute: 30,
+      schedule_hour: 14,
+    };
+    const cron = scheduleSettingsToCron(settings);
+    expect(cron).toEqual("0 30 14 * * ?");
+  });
+
+  it("converts weekly schedule to cron", () => {
+    const settings: ScheduleSettings = {
+      schedule_type: "weekly",
+      schedule_day: "mon",
+      schedule_minute: 0,
+      schedule_hour: 12,
+    };
+    const cron = scheduleSettingsToCron(settings);
+    expect(cron).toEqual("0 0 12 ? * 2");
+  });
+
+  it("converts 'first Wednesday of the month at 9:15am' to cron", () => {
+    const settings: ScheduleSettings = {
+      schedule_type: "monthly",
+      schedule_day: "wed",
+      schedule_frame: "first",
+      schedule_minute: 15,
+      schedule_hour: 9,
+    };
+    const cron = scheduleSettingsToCron(settings);
+    expect(cron).toEqual("0 15 9 ? * 4#1");
+  });
+
+  it("converts 'last calendar day of the month' to cron", () => {
+    const settings: ScheduleSettings = {
+      schedule_type: "monthly",
+      schedule_frame: "last",
+      schedule_minute: 45,
+      schedule_hour: 16,
+    };
+    const cron = scheduleSettingsToCron(settings);
+    expect(cron).toEqual("0 45 16 L * ?");
+  });
+
+  it("converts 'monthly on the 15th' to cron", () => {
+    const settings: ScheduleSettings = {
+      schedule_type: "monthly",
+      schedule_frame: "mid",
+      schedule_minute: 5,
+      schedule_hour: 23,
+    };
+    const cron = scheduleSettingsToCron(settings);
+    expect(cron).toEqual("0 5 23 15 * ?");
+  });
+
+  it("missing minute and hour should default to wildcard", () => {
+    const settings: ScheduleSettings = {
+      schedule_type: "daily",
+    };
+    const cron = scheduleSettingsToCron(settings);
+    expect(cron).toEqual("0 * * * * ?");
+  });
+});
+
+describe("cronToScheduleSettings", () => {
+  it("returns default schedule when input is null or undefined", () => {
+    const defaultSchedule = {
+      schedule_type: "hourly",
+      schedule_minute: 0,
+    };
+    expect(cronToScheduleSettings(null)).toEqual(defaultSchedule);
+    expect(cronToScheduleSettings(undefined)).toEqual(defaultSchedule);
+  });
+
+  it("returns null when month is specified in cron", () => {
+    const cron = "0 15 10 15 1 ?";
+    expect(cronToScheduleSettings(cron)).toBeNull();
+  });
+
+  describe("schedule type determination", () => {
+    it('sets schedule type to "hourly" when hour is "*" and both dayOfMonth and dayOfWeek are "*"', () => {
+      const cron = "0 30 * * * *";
+      expect(cronToScheduleSettings(cron)?.schedule_type).toBe("hourly");
+    });
+
+    it('sets schedule type to "daily" when dayOfMonth and dayOfWeek are "*", and hour is specified', () => {
+      const cron = "0 30 8 * * *";
+      expect(cronToScheduleSettings(cron)?.schedule_type).toBe("daily");
+    });
+
+    it('sets schedule type to "weekly" when dayOfWeek is specified', () => {
+      const cron = "0 30 8 ? * 1";
+      expect(cronToScheduleSettings(cron)?.schedule_type).toBe("weekly");
+    });
+
+    it('sets schedule type to "monthly" when dayOfMonth is specific', () => {
+      const cron = "0 30 8 15 * ?";
+      expect(cronToScheduleSettings(cron)?.schedule_type).toBe("monthly");
+    });
+  });
+
+  describe("monthly schedule determination", () => {
+    it('sets schedule frame to "mid" when dayOfMonth is "15"', () => {
+      const cron = "0 30 8 15 * ?";
+      expect(cronToScheduleSettings(cron)?.schedule_frame).toBe("mid");
+    });
+
+    it('sets schedule frame to "first" when dayOfMonth is "1"', () => {
+      const cron = "0 30 8 1 * ?";
+      expect(cronToScheduleSettings(cron)?.schedule_frame).toBe("first");
+    });
+
+    it('sets schedule frame to "last" when dayOfMonth is "L"', () => {
+      const cron = "0 30 8 L * ?";
+      expect(cronToScheduleSettings(cron)?.schedule_frame).toBe("last");
+    });
+  });
+
+  describe("weekly schedule determination", () => {
+    it('sets schedule_day to "sun" when dayOfWeek is 1', () => {
+      const cron = "0 30 8 ? * 1";
+      expect(cronToScheduleSettings(cron)?.schedule_day).toBe("sun");
+    });
+
+    it('sets schedule_day to "mon" when dayOfWeek is 2', () => {
+      const cron = "0 30 8 ? * 2";
+      expect(cronToScheduleSettings(cron)?.schedule_day).toBe("mon");
+    });
+
+    it('sets schedule_day to "tue" when dayOfWeek is 3', () => {
+      const cron = "0 30 8 ? * 3";
+      expect(cronToScheduleSettings(cron)?.schedule_day).toBe("tue");
+    });
+
+    it('sets schedule_day to "wed" when dayOfWeek is 4', () => {
+      const cron = "0 30 8 ? * 4";
+      expect(cronToScheduleSettings(cron)?.schedule_day).toBe("wed");
+    });
+
+    it('sets schedule_day to "thu" when dayOfWeek is 5', () => {
+      const cron = "0 30 8 ? * 5";
+      expect(cronToScheduleSettings(cron)?.schedule_day).toBe("thu");
+    });
+
+    it('sets schedule_day to "fri" when dayOfWeek is 6', () => {
+      const cron = "0 30 8 ? * 6";
+      expect(cronToScheduleSettings(cron)?.schedule_day).toBe("fri");
+    });
+
+    it('sets schedule_day to "sat" when dayOfWeek is 7', () => {
+      const cron = "0 30 8 ? * 7";
+      expect(cronToScheduleSettings(cron)?.schedule_day).toBe("sat");
+    });
+    it('sets schedule_day to undefined if dayOfWeek is "?"', () => {
+      const cron = "0 30 8 ? * ?";
+      expect(cronToScheduleSettings(cron)?.schedule_day).toBeUndefined();
+    });
+  });
+
+  describe("hours and minutes extraction", () => {
+    it("correctly extracts the hour and minute from the cron", () => {
+      const cron = "0 20 3 * * *";
+      const result = cronToScheduleSettings(cron);
+      expect(result).toEqual(
+        expect.objectContaining({
+          schedule_minute: 20,
+          schedule_hour: 3,
+        }),
+      );
+    });
+  });
+
+  describe("specific cron strings", () => {
+    it("converts 0 0 22 ? * 2L correctly", () => {
+      const cron = "0 0 22 ? * 2L";
+      expect(cronToScheduleSettings(cron)).toEqual({
+        schedule_type: "monthly",
+        schedule_minute: 0,
+        schedule_hour: 22,
+        schedule_day: "mon",
+        schedule_frame: "last",
+      });
+    });
+    it("converts 0 0 6 ? * 6#1 correctly", () => {
+      const cron = "0 0 6 ? * 6#1";
+      expect(cronToScheduleSettings(cron)).toEqual({
+        schedule_type: "monthly",
+        schedule_minute: 0,
+        schedule_hour: 6,
+        schedule_day: "fri",
+        schedule_frame: "first",
+      });
+    });
+  });
+});
+
+describe("hourToTwelveHourFormat", () => {
+  it("converts 24-hour format to 12-hour format correctly", () => {
+    expect(hourToTwelveHourFormat(0)).toBe(12);
+    expect(hourToTwelveHourFormat(13)).toBe(1);
+    expect(hourToTwelveHourFormat(23)).toBe(11);
+    expect(hourToTwelveHourFormat(12)).toBe(12);
+  });
+  it("does not change hours that are already in 12-hour format", () => {
+    expect(hourToTwelveHourFormat(11)).toBe(11);
+    expect(hourToTwelveHourFormat(10)).toBe(10);
+    expect(hourToTwelveHourFormat(1)).toBe(1);
+  });
+});
+
+describe("removeFalsyValues", () => {
+  it("removes falsy values from an object", () => {
+    const obj = {
+      a: 1,
+      b: false,
+      c: null,
+      d: 0,
+      e: "",
+      f: "some string",
+      g: undefined,
+    };
+    const truthyObj = { a: 1, f: "some string" };
+    expect(removeFalsyValues(obj)).toEqual(truthyObj);
+  });
+  it("keeps truthy values", () => {
+    const obj = { a: 1, b: true, c: "string", d: [], e: {} };
+    expect(removeFalsyValues(obj)).toEqual(obj);
+  });
+});
diff --git a/frontend/src/metabase/components/Schedule/Schedule.stories.tsx b/frontend/src/metabase/components/Schedule/Schedule.stories.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..fdce96762fc5ba63fbbfa37f48a38869bda1bcc3
--- /dev/null
+++ b/frontend/src/metabase/components/Schedule/Schedule.stories.tsx
@@ -0,0 +1,41 @@
+import { useArgs } from "@storybook/addons";
+import type { ComponentStory } from "@storybook/react";
+
+import { Schedule } from "./Schedule";
+
+export default {
+  title: "Components/Schedule",
+  component: Schedule,
+};
+
+const Template: ComponentStory<typeof Schedule> = args => {
+  const [
+    {
+      schedule,
+      scheduleOptions = ["hourly", "daily", "weekly", "monthly"],
+      timezone = "UTC",
+    },
+    updateArgs,
+  ] = useArgs();
+  const handleChange = (schedule: unknown) => updateArgs({ schedule });
+  return (
+    <Schedule
+      {...args}
+      schedule={schedule}
+      scheduleOptions={scheduleOptions}
+      timezone={timezone}
+      onScheduleChange={handleChange}
+    />
+  );
+};
+
+export const Default = Template.bind({});
+Default.args = {
+  schedule: {
+    schedule_day: "mon",
+    schedule_frame: null,
+    schedule_hour: 0,
+    schedule_type: "daily",
+  },
+  verb: "Deliver",
+};
diff --git a/frontend/src/metabase/components/Schedule/Schedule.tsx b/frontend/src/metabase/components/Schedule/Schedule.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..52a0baeaa0ccb86fbac329e73f8bb61dbad3a92a
--- /dev/null
+++ b/frontend/src/metabase/components/Schedule/Schedule.tsx
@@ -0,0 +1,281 @@
+import { useCallback } from "react";
+import { c } from "ttag";
+
+import { removeFalsyValues } from "metabase/admin/performance/utils";
+import { capitalize } from "metabase/lib/formatting/strings";
+import { Box } from "metabase/ui";
+import type { ScheduleSettings, ScheduleType } from "metabase-types/api";
+
+import {
+  AutoWidthSelect,
+  TimeDetails,
+  SelectFrame,
+  SelectMinute,
+  SelectTime,
+  SelectWeekday,
+  SelectWeekdayOfMonth,
+} from "./components";
+import { defaultDay, optionNameTranslations } from "./constants";
+import type { ScheduleChangeProp, UpdateSchedule } from "./types";
+
+type ScheduleProperty = keyof ScheduleSettings;
+
+const getOptionName = (option: ScheduleType) =>
+  optionNameTranslations[option] || capitalize(option);
+
+export interface ScheduleProps {
+  schedule: ScheduleSettings;
+  scheduleOptions: ScheduleType[];
+  onScheduleChange: (
+    nextSchedule: ScheduleSettings,
+    change: ScheduleChangeProp,
+  ) => void;
+  timezone?: string;
+  verb?: string;
+  textBeforeSendTime?: string;
+  minutesOnHourPicker?: boolean;
+}
+
+const defaults: Record<string, Partial<ScheduleSettings>> = {
+  hourly: {
+    schedule_day: null,
+    schedule_frame: null,
+    schedule_hour: null,
+    schedule_minute: 0,
+  },
+  daily: {
+    schedule_day: null,
+    schedule_frame: null,
+  },
+  weekly: {
+    schedule_day: defaultDay,
+    schedule_frame: null,
+  },
+  monthly: {
+    schedule_frame: "first",
+    schedule_day: defaultDay,
+  },
+};
+
+export const Schedule = ({
+  schedule,
+  scheduleOptions,
+  timezone,
+  verb,
+  textBeforeSendTime,
+  minutesOnHourPicker,
+  onScheduleChange,
+}: {
+  schedule: ScheduleSettings;
+  scheduleOptions: ScheduleType[];
+  timezone?: string;
+  verb?: string;
+  textBeforeSendTime?: string;
+  minutesOnHourPicker?: boolean;
+  onScheduleChange: (
+    nextSchedule: ScheduleSettings,
+    change: ScheduleChangeProp,
+  ) => void;
+}) => {
+  const updateSchedule: UpdateSchedule = useCallback(
+    (field: ScheduleProperty, value: ScheduleSettings[typeof field]) => {
+      let newSchedule: ScheduleSettings = {
+        ...schedule,
+        [field]: value,
+      };
+
+      newSchedule = removeFalsyValues(newSchedule);
+
+      if (field === "schedule_type") {
+        newSchedule = {
+          ...defaults[value as ScheduleType],
+          ...newSchedule,
+        };
+      } else if (field === "schedule_frame") {
+        // when the monthly schedule frame is the 15th, clear out the schedule_day
+        if (value === "mid") {
+          newSchedule = { ...newSchedule, schedule_day: null };
+        } else {
+          // first or last, needs a day of the week
+          newSchedule = {
+            schedule_day: newSchedule.schedule_day || defaultDay,
+            ...newSchedule,
+          };
+        }
+      }
+
+      onScheduleChange(newSchedule, { name: field, value });
+    },
+    [onScheduleChange, schedule],
+  );
+
+  return (
+    <Box lh="41px" display="flex" style={{ flexWrap: "wrap", gap: ".5rem" }}>
+      <ScheduleBody
+        schedule={schedule}
+        updateSchedule={updateSchedule}
+        scheduleOptions={scheduleOptions}
+        timezone={timezone}
+        verb={verb}
+        textBeforeSendTime={textBeforeSendTime}
+        minutesOnHourPicker={minutesOnHourPicker}
+      />
+    </Box>
+  );
+};
+
+const ScheduleBody = ({
+  schedule,
+  updateSchedule,
+  scheduleOptions,
+  timezone,
+  verb,
+  textBeforeSendTime,
+  minutesOnHourPicker,
+}: Omit<ScheduleProps, "onScheduleChange"> & {
+  updateSchedule: UpdateSchedule;
+}) => {
+  const itemProps = {
+    schedule,
+    updateSchedule,
+  };
+
+  const frequencyProps = {
+    ...itemProps,
+    key: "frequency",
+    scheduleType: schedule.schedule_type,
+    scheduleOptions,
+  };
+  const timeProps = {
+    ...itemProps,
+    key: "time",
+  };
+  const minuteProps = {
+    ...itemProps,
+    key: "minute",
+  };
+  const weekdayProps = {
+    ...itemProps,
+    key: "weekday",
+  };
+  const frameProps = {
+    ...itemProps,
+    key: "frame",
+  };
+  const weekdayOfMonthProps = {
+    ...itemProps,
+    key: "weekday-of-month",
+  };
+  const timeDetailsProps = {
+    key: "time-details",
+    hour: schedule.schedule_hour ?? null,
+    amPm: schedule.schedule_hour
+      ? schedule.schedule_hour >= 12
+        ? 1
+        : 0
+      : null,
+    timezone: timezone ?? null,
+    textBeforeSendTime,
+  };
+
+  const scheduleType = schedule.schedule_type;
+
+  if (scheduleType === "hourly") {
+    if (minutesOnHourPicker) {
+      // e.g. "Send hourly at 15 minutes past the hour"
+      return (
+        <>{c(
+          "{0} is a verb like 'Send', {1} is an adverb like 'hourly', {2} is a number of minutes",
+        ).jt`${verb} ${(<SelectFrequency {...frequencyProps} />)} at ${(
+          <SelectMinute {...minuteProps} />
+        )} minutes past the hour`}</>
+      );
+    } else {
+      // e.g. "Send hourly"
+      return (
+        <>
+          {verb} <SelectFrequency {...frequencyProps} />
+        </>
+      );
+    }
+  } else if (scheduleType === "daily") {
+    // e.g. "Send daily at 12:00pm"
+    return (
+      <>
+        {c(
+          "{0} is a verb like 'Send', {1} is an adverb like 'hourly', {2} is a time like '12:00pm'",
+        ).jt`${verb} ${(<SelectFrequency {...frequencyProps} />)} at ${(
+          <SelectTime {...timeProps} />
+        )}`}
+        <TimeDetails {...timeDetailsProps} />
+      </>
+    );
+  } else if (scheduleType === "weekly") {
+    // e.g. "Send weekly on Tuesday at 12:00pm"
+    return (
+      <>
+        {c(
+          "{0} is a verb like 'Send', {1} is an adverb like 'hourly', {2} is a day like 'Tuesday', {3} is a time like '12:00pm'",
+        ).jt`${verb} ${(<SelectFrequency {...frequencyProps} />)} on ${(
+          <SelectWeekday {...weekdayProps} />
+        )} at ${(<SelectTime {...timeProps} />)}`}
+        <TimeDetails {...timeDetailsProps} />
+      </>
+    );
+  } else if (scheduleType === "monthly") {
+    // e.g. "Send monthly on the 15th at 12:00pm"
+    if (schedule.schedule_frame === "mid") {
+      return (
+        <>
+          {c(
+            "{0} is a verb like 'Send', {1} is an adverb like 'hourly', {2} is the noun '15th' (as in 'the 15th of the month'), {3} is a time like '12:00pm'",
+          ).jt`${verb} ${(<SelectFrequency {...frequencyProps} />)} on the ${(
+            <SelectFrame {...frameProps} />
+          )} at ${(<SelectTime {...timeProps} />)} `}
+          <TimeDetails {...timeDetailsProps} />
+        </>
+      );
+    } else {
+      // e.g. "Send monthly on the first Tuesday at 12:00pm"
+      return (
+        <>
+          {c(
+            "{0} is a verb like 'Send', {1} is an adverb like 'hourly', {2} is an adjective like 'first', {3} is a day like 'Tuesday', {4} is a time like '12:00pm'",
+          ).jt`${verb} ${(<SelectFrequency {...frequencyProps} />)} on the ${(
+            <SelectFrame {...frameProps} />
+          )} ${(<SelectWeekdayOfMonth {...weekdayOfMonthProps} />)} at ${(
+            <SelectTime {...timeProps} />
+          )}`}
+        </>
+      );
+    }
+  } else {
+    return null;
+  }
+};
+
+/** A Select that changes the schedule frequency (e.g., daily, hourly, monthly, etc.),
+ * also known as the schedule 'type'. */
+const SelectFrequency = ({
+  scheduleType,
+  updateSchedule,
+  scheduleOptions,
+}: {
+  scheduleType?: ScheduleType | null;
+  updateSchedule: UpdateSchedule;
+  scheduleOptions: ScheduleType[];
+}) => {
+  const scheduleTypeOptions = scheduleOptions.map(option => ({
+    label: getOptionName(option),
+    value: option,
+  }));
+
+  return (
+    <AutoWidthSelect
+      display="flex"
+      value={scheduleType}
+      onChange={(value: ScheduleType) => updateSchedule("schedule_type", value)}
+      data={scheduleTypeOptions}
+    />
+  );
+};
diff --git a/frontend/src/metabase/components/Schedule/components.tsx b/frontend/src/metabase/components/Schedule/components.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..11e8e31165318a6d3be3e70ddf2410f6cf8678fd
--- /dev/null
+++ b/frontend/src/metabase/components/Schedule/components.tsx
@@ -0,0 +1,186 @@
+import { useMemo } from "react";
+import { t } from "ttag";
+
+import { hourToTwelveHourFormat } from "metabase/admin/performance/utils";
+import { useSelector } from "metabase/lib/redux";
+import { getApplicationName } from "metabase/selectors/whitelabel";
+import type { SelectProps } from "metabase/ui";
+import { Group, SegmentedControl, Select, Text } from "metabase/ui";
+import type {
+  ScheduleDayType,
+  ScheduleFrameType,
+  ScheduleSettings,
+} from "metabase-types/api";
+
+import {
+  addZeroesToHour,
+  amAndPM,
+  defaultHour,
+  frames,
+  hours,
+  minutes,
+  weekdays,
+} from "./constants";
+import type { UpdateSchedule } from "./types";
+
+export const SelectFrame = ({
+  schedule,
+  updateSchedule,
+}: {
+  schedule: ScheduleSettings;
+  updateSchedule: UpdateSchedule;
+}) => {
+  return (
+    <AutoWidthSelect
+      value={schedule.schedule_frame}
+      onChange={(value: ScheduleFrameType) =>
+        updateSchedule("schedule_frame", value)
+      }
+      data={frames}
+    />
+  );
+};
+
+export const SelectTime = ({
+  schedule,
+  updateSchedule,
+}: {
+  schedule: ScheduleSettings;
+  updateSchedule: UpdateSchedule;
+}) => {
+  const hourIn24HourFormat =
+    schedule.schedule_hour && !isNaN(schedule.schedule_hour)
+      ? schedule.schedule_hour
+      : defaultHour;
+  const hour = hourToTwelveHourFormat(hourIn24HourFormat);
+  const amPm = hourIn24HourFormat >= 12 ? 1 : 0;
+  return (
+    <Group spacing="xs">
+      <AutoWidthSelect
+        value={hour.toString()}
+        data={hours}
+        onChange={(value: string) =>
+          updateSchedule("schedule_hour", Number(value) + amPm * 12)
+        }
+      />
+      <SegmentedControl
+        radius="sm"
+        value={amPm.toString()}
+        onChange={value =>
+          updateSchedule("schedule_hour", hour + Number(value) * 12)
+        }
+        data={amAndPM}
+      />
+    </Group>
+  );
+};
+
+export const TimeDetails = ({
+  hour,
+  amPm,
+  timezone,
+  textBeforeSendTime,
+}: {
+  hour: number | null;
+  amPm: number | null;
+  timezone: string | null;
+  textBeforeSendTime?: string;
+}) => {
+  const applicationName = useSelector(getApplicationName);
+  if (hour === null || amPm === null || !timezone) {
+    return null;
+  }
+  const time = addZeroesToHour(hourToTwelveHourFormat(hour));
+  const amOrPM = amAndPM[amPm].label;
+  return (
+    <Text w="100%" mt="xs" size="sm" fw="bold" color="text-light">
+      {textBeforeSendTime} {time} {amOrPM} {timezone},{" "}
+      {t`your ${applicationName} timezone`}
+    </Text>
+  );
+};
+
+export const SelectWeekday = ({
+  schedule,
+  updateSchedule,
+}: {
+  schedule: ScheduleSettings;
+  updateSchedule: UpdateSchedule;
+}) => {
+  return (
+    <AutoWidthSelect
+      value={schedule.schedule_day}
+      onChange={(value: ScheduleDayType) =>
+        updateSchedule("schedule_day", value)
+      }
+      data={weekdays}
+    />
+  );
+};
+
+/** Selects the weekday of the month, e.g. the first Monday of the month
+  "First" is selected via SelectFrame. This component provides the weekday */
+export const SelectWeekdayOfMonth = ({
+  schedule,
+  updateSchedule,
+}: {
+  schedule: ScheduleSettings;
+  updateSchedule: UpdateSchedule;
+}) => (
+  <AutoWidthSelect
+    value={schedule.schedule_day || "calendar-day"}
+    onChange={(value: ScheduleDayType | "calendar-day") =>
+      updateSchedule("schedule_day", value === "calendar-day" ? null : value)
+    }
+    data={[{ label: t`calendar day`, value: "calendar-day" }, ...weekdays]}
+  />
+);
+
+export const SelectMinute = ({
+  schedule,
+  updateSchedule,
+}: {
+  schedule: ScheduleSettings;
+  updateSchedule: UpdateSchedule;
+}) => {
+  const minuteOfHour = isNaN(schedule.schedule_minute as number)
+    ? 0
+    : schedule.schedule_minute;
+  return (
+    <AutoWidthSelect
+      value={(minuteOfHour || 0).toString()}
+      data={minutes}
+      onChange={(value: string) =>
+        updateSchedule("schedule_minute", Number(value))
+      }
+    />
+  );
+};
+
+export const AutoWidthSelect = (props: SelectProps) => {
+  const longestLabel = useMemo(
+    () =>
+      props.data.reduce((acc, option) => {
+        const label = typeof option === "string" ? option : option.label || "";
+        return label.length > acc.length ? label : acc;
+      }, ""),
+    [props.data],
+  );
+  const maxWidth =
+    longestLabel.length > 10 ? "unset" : `${longestLabel.length + 0.75}rem`;
+  return (
+    <Select
+      miw="5rem"
+      maw={maxWidth}
+      styles={{
+        wrapper: {
+          paddingRight: 0,
+          marginTop: 0,
+          "&:not(:only-child)": { marginTop: "0" },
+        },
+        input: { paddingRight: 0 },
+      }}
+      {...props}
+    />
+  );
+};
diff --git a/frontend/src/metabase/components/Schedule/constants.ts b/frontend/src/metabase/components/Schedule/constants.ts
new file mode 100644
index 0000000000000000000000000000000000000000..33142ba56402a1f37eeb383d4871bf1b01c72d72
--- /dev/null
+++ b/frontend/src/metabase/components/Schedule/constants.ts
@@ -0,0 +1,62 @@
+import { c, t } from "ttag";
+import { times } from "underscore";
+
+import type { ScheduleDayType } from "metabase-types/api";
+
+export const minutes = times(60, n => ({
+  label: n.toString(),
+  value: n.toString(),
+}));
+
+export const addZeroesToHour = (hour: number) =>
+  c("This is a time like 12:00pm. {0} is the hour part of the time").t`${
+    hour || 12
+  }:00`;
+
+export const hours = times(12, n => ({
+  label: addZeroesToHour(n),
+  value: `${n}`,
+}));
+
+export const optionNameTranslations = {
+  // The context is needed because 'hourly' can be an adjective ('hourly rate') or adverb ('update hourly'). Same with 'daily', 'weekly', and 'monthly'.
+  hourly: c("adverb").t`hourly`,
+  daily: c("adverb").t`daily`,
+  weekly: c("adverb").t`weekly`,
+  monthly: c("adverb").t`monthly`,
+};
+
+export type Weekday = {
+  label: string;
+  value: ScheduleDayType;
+};
+
+export const weekdays: Weekday[] = [
+  { label: t`Sunday`, value: "sun" },
+  { label: t`Monday`, value: "mon" },
+  { label: t`Tuesday`, value: "tue" },
+  { label: t`Wednesday`, value: "wed" },
+  { label: t`Thursday`, value: "thu" },
+  { label: t`Friday`, value: "fri" },
+  { label: t`Saturday`, value: "sat" },
+];
+
+export const amAndPM = [
+  { label: c("As in 9:00 AM").t`AM`, value: "0" },
+  { label: c("As in 9:00 PM").t`PM`, value: "1" },
+];
+
+export const frames = [
+  { label: t`first`, value: "first" },
+  { label: t`last`, value: "last" },
+  { label: t`15th`, value: "mid" },
+];
+
+export const defaultDay = "mon";
+export const defaultHour = 8;
+
+export enum Cron {
+  AllValues = "*",
+  NoSpecificValue = "?",
+  NoSpecificValue_Escaped = "\\?",
+}
diff --git a/frontend/src/metabase/components/Schedule/types.ts b/frontend/src/metabase/components/Schedule/types.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9196ae660a48ac6bc3ef6fddf691495747322872
--- /dev/null
+++ b/frontend/src/metabase/components/Schedule/types.ts
@@ -0,0 +1,9 @@
+import type { ScheduleSettings } from "metabase-types/api";
+
+type ScheduleProperty = keyof ScheduleSettings;
+export type ScheduleChangeProp = { name: ScheduleProperty; value: unknown };
+
+export type UpdateSchedule = (
+  field: ScheduleProperty,
+  value: ScheduleSettings[typeof field],
+) => void;