diff --git a/e2e/support/config.js b/e2e/support/config.js
index 97f59cccedf736b429e0e1a68e76758a24039662..e12112ae637dba4b36208d83e89a8469ea6eed9c 100644
--- a/e2e/support/config.js
+++ b/e2e/support/config.js
@@ -1,3 +1,5 @@
+import path from "node:path";
+
 import {
   removeDirectory,
   verifyDownloadTasks,
@@ -21,6 +23,23 @@ const targetVersion = process.env["CROSS_VERSION_TARGET"];
 
 const feHealthcheckEnabled = process.env["CYPRESS_FE_HEALTHCHECK"] === "true";
 
+// docs say that tsconfig paths should handle aliases, but they don't
+const assetsResolverPlugin = {
+  name: "assetsResolver",
+  setup(build) {
+    // Redirect all paths starting with "assets/" to "resources/"
+    build.onResolve({ filter: /^assets\// }, args => {
+      return {
+        path: path.join(
+          __dirname,
+          "../../resources/frontend_client/app",
+          args.path,
+        ),
+      };
+    });
+  },
+};
+
 const defaultConfig = {
   // This is the functionality of the old cypress-plugins.js file
   setupNodeEvents(on, config) {
@@ -36,7 +55,7 @@ const defaultConfig = {
         loader: {
           ".svg": "text",
         },
-        plugins: [NodeModulesPolyfillPlugin()],
+        plugins: [NodeModulesPolyfillPlugin(), assetsResolverPlugin],
         sourcemap: "inline",
       }),
     );
diff --git a/e2e/test/scenarios/admin/performance/helpers/e2e-performance-helpers.ts b/e2e/test/scenarios/admin/performance/helpers/e2e-performance-helpers.ts
new file mode 100644
index 0000000000000000000000000000000000000000..aedde7d6368604f7ce6582ca930a4022213b4ad6
--- /dev/null
+++ b/e2e/test/scenarios/admin/performance/helpers/e2e-performance-helpers.ts
@@ -0,0 +1,32 @@
+import dayjs from "dayjs";
+import duration from "dayjs/plugin/duration";
+import relativeTime from "dayjs/plugin/relativeTime";
+
+dayjs.extend(duration);
+dayjs.extend(relativeTime);
+
+/** Intercept routes for caching tests */
+export const interceptRoutes = () => {
+  cy.intercept("POST", "/api/dataset").as("dataset");
+  cy.intercept("POST", "/api/card/*/query").as("cardQuery");
+  cy.intercept("PUT", "/api/cache").as("putCacheConfig");
+  cy.intercept("DELETE", "/api/cache").as("deleteCacheConfig");
+  cy.intercept("GET", "/api/cache?model=*&id=*").as("getCacheConfig");
+  cy.intercept("POST", "/api/dashboard/*/dashcard/*/card/*/query").as(
+    "dashcardQuery",
+  );
+  cy.intercept("POST", "/api/persist/enable").as("enablePersistence");
+  cy.intercept("POST", "/api/persist/disable").as("disablePersistence");
+  cy.intercept("POST", "/api/cache/invalidate?include=overrides&database=*").as(
+    "invalidateCacheForSampleDatabase",
+  );
+};
+
+/** Cypress log messages sometimes occur out of order so it is helpful to log to the console as well. */
+export const log = (message: string) => {
+  cy.log(message);
+  console.log(message);
+};
+
+export const databaseCachingSettingsPage = () =>
+  cy.findByRole("main", { name: "Database caching settings" });
diff --git a/e2e/test/scenarios/admin/performance/helpers/e2e-strategy-form-helpers.ts b/e2e/test/scenarios/admin/performance/helpers/e2e-strategy-form-helpers.ts
new file mode 100644
index 0000000000000000000000000000000000000000..34fbd3e6ad06d236c0041edd4a26e5554e217ace
--- /dev/null
+++ b/e2e/test/scenarios/admin/performance/helpers/e2e-strategy-form-helpers.ts
@@ -0,0 +1,93 @@
+import {
+  type ScheduleComponentType,
+  getScheduleComponentLabel,
+} from "metabase/components/Schedule/constants";
+import type { CacheStrategyType, CacheableModel } from "metabase-types/api";
+
+import { databaseCachingSettingsPage } from "./e2e-performance-helpers";
+
+/** Save the cache strategy form and wait for a response from the relevant endpoint */
+export const saveCacheStrategyForm = (options?: {
+  strategyType?: CacheStrategyType;
+  /** 'Model' as in 'type of object' */
+  model?: CacheableModel;
+}) => {
+  let expectedRoute: string;
+  if (options?.strategyType === "nocache" && options?.model === "root") {
+    // When setting the default policy to "Don't cache", we delete the policy in the BE
+    expectedRoute = "@deleteCacheConfig";
+  } else if (options?.strategyType === "inherit") {
+    // When setting a database's policy to "Use default", we delete the policy in the BE
+    expectedRoute = "@deleteCacheConfig";
+  } else {
+    // Otherwise we update the cache config
+    expectedRoute = "@putCacheConfig";
+  }
+  cy.log("Save the cache strategy form");
+  cacheStrategyForm().button(/Save/).click();
+  return cy.wait(expectedRoute);
+};
+
+export const cacheStrategyForm = () =>
+  cy.findByLabelText("Select the cache invalidation policy");
+
+export const cacheStrategyRadioButton = (name: RegExp) =>
+  cacheStrategyForm().findByRole("radio", { name });
+
+export const durationRadioButton = () => cacheStrategyRadioButton(/Duration/);
+export const adaptiveRadioButton = () => cacheStrategyRadioButton(/Adaptive/);
+export const scheduleRadioButton = () => cacheStrategyRadioButton(/Schedule/);
+export const dontCacheResultsRadioButton = () =>
+  cacheStrategyRadioButton(/Don.t cache results/);
+export const useDefaultRadioButton = () =>
+  cacheStrategyRadioButton(/Use default/);
+
+export const formLauncher = (
+  itemName: string,
+  preface:
+    | "currently"
+    | "currently inheriting"
+    | "currently inheriting the default policy",
+  strategyLabel = "",
+) => {
+  databaseCachingSettingsPage().should("exist");
+  const regExp = new RegExp(`Edit.*${itemName}.*${preface}.*${strategyLabel}`);
+  cy.log(`Finding strategy for launcher for regular expression: ${regExp}`);
+  const launcher = databaseCachingSettingsPage().findByLabelText(regExp);
+  launcher.should("exist");
+  return launcher;
+};
+
+export const openStrategyFormForDatabaseOrDefaultPolicy = (
+  /** To open the form for the default policy, set this parameter to "default policy" */
+  databaseNameOrDefaultPolicy: string,
+  currentStrategyLabel?: string,
+) => {
+  cy.visit("/admin/performance");
+  cy.findByRole("tab", { name: "Database caching settings" }).click();
+  cy.log(`Open strategy form for ${databaseNameOrDefaultPolicy}`);
+  formLauncher(
+    databaseNameOrDefaultPolicy,
+    "currently",
+    currentStrategyLabel,
+  ).click();
+};
+
+export const getScheduleComponent = (componentType: ScheduleComponentType) =>
+  cacheStrategyForm().findByLabelText(getScheduleComponentLabel(componentType));
+
+export const openSidebar = () => {
+  cy.findByLabelText("info icon").click();
+};
+export const closeSidebar = () => {
+  cy.findByLabelText("info icon").click();
+};
+
+/** Open the sidebar form that lets you set the caching strategy.
+ * This works on dashboards and questions */
+export const openSidebarCacheStrategyForm = () => {
+  cy.log("Open the cache strategy form in the sidebar");
+  openSidebar();
+  cy.wait("@getCacheConfig");
+  cy.findByLabelText("Caching policy").click();
+};
diff --git a/e2e/test/scenarios/admin/performance/performance.cy.spec.ts b/e2e/test/scenarios/admin/performance/performance.cy.spec.ts
index db63499714f260e0d1a3b581b08f391e4de44942..874064e860ca242b342f5c39c0dc628e561726f6 100644
--- a/e2e/test/scenarios/admin/performance/performance.cy.spec.ts
+++ b/e2e/test/scenarios/admin/performance/performance.cy.spec.ts
@@ -1,46 +1,17 @@
 import { describeEE, restore, setTokenFeatures } from "e2e/support/helpers";
 
-const save = ({ expectedRoute = "@putCacheConfig" } = {}) => {
-  cy.log("Save the caching strategy form");
-  cy.button(/Save/).click();
-  cy.wait(expectedRoute);
-};
-
-const launchFormForItem = (itemName: string, currentStrategy: string) => {
-  cy.visit("/admin");
-  cy.findByRole("link", { name: "Performance" }).click();
-  cy.findByRole("tab", { name: "Database caching settings" }).click();
-  cy.log(`Open strategy form for ${itemName}`);
-  formLauncher(itemName, "currently", currentStrategy).click();
-};
-
-const main = () => cy.findByRole("main", { name: "Database caching settings" });
-
-const form = () => cy.findByLabelText("Select the cache invalidation policy");
-
-const radio = (name: RegExp) => form().findByRole("radio", { name });
-
-const durationRadioButton = () => radio(/Duration/);
-const adaptiveRadioButton = () => radio(/Adaptive/);
-const scheduleRadioButton = () => radio(/Schedule/);
-const dontCacheResultsRadioButton = () => radio(/Don.t cache results/);
-const useDefaultRadioButton = () => radio(/Use default/);
-
-const formLauncher = (
-  itemName: string,
-  preface: string,
-  strategyLabel: string,
-) => {
-  main().should("exist");
-  cy.log(
-    `Finding strategy for launcher for regex: Edit.*${itemName}.*${preface}.*${strategyLabel}`,
-  );
-  const launcher = main().findByLabelText(
-    new RegExp(`Edit.*${itemName}.*${preface}.*${strategyLabel}`),
-  );
-  launcher.should("exist");
-  return launcher;
-};
+import {
+  adaptiveRadioButton,
+  dontCacheResultsRadioButton,
+  formLauncher,
+  durationRadioButton,
+  useDefaultRadioButton,
+  scheduleRadioButton,
+  cacheStrategyForm,
+  openStrategyFormForDatabaseOrDefaultPolicy,
+  saveCacheStrategyForm,
+  cacheStrategyRadioButton,
+} from "./helpers/e2e-strategy-form-helpers";
 
 // NOTE: These tests just check that the form can be saved. They do not test
 // whether the cache is actually invalidated at the specified times.
@@ -86,36 +57,35 @@ describe("scenarios > admin > performance", { tags: "@OSS" }, () => {
   });
 
   it("there are two policy options for the default policy, Adaptive and Don't cache results", () => {
-    form().findAllByRole("radio").should("have.length", 2);
+    cacheStrategyForm().findAllByRole("radio").should("have.length", 2);
     adaptiveRadioButton().should("exist");
     dontCacheResultsRadioButton().should("exist");
   });
 
   it("can set default policy to Don't cache results", () => {
+    const model = "root";
     cy.log("Set default policy to Adaptive first");
     adaptiveRadioButton().click();
-    save();
+    saveCacheStrategyForm({ strategyType: "ttl", model });
     adaptiveRadioButton().should("be.checked");
 
     cy.log("Then set default policy to Don't cache results");
     dontCacheResultsRadioButton().click();
-    save({
-      expectedRoute: "@deleteCacheConfig",
-    });
+    saveCacheStrategyForm({ strategyType: "nocache", model });
     dontCacheResultsRadioButton().should("be.checked");
   });
 
   describe("adaptive strategy", () => {
     it("can set default policy to adaptive", () => {
       adaptiveRadioButton().click();
-      save();
+      saveCacheStrategyForm({ strategyType: "ttl", model: "root" });
       adaptiveRadioButton().should("be.checked");
     });
 
     it("can configure a minimum query duration for the default adaptive policy", () => {
       adaptiveRadioButton().click();
       cy.findByLabelText(/Minimum query duration/).type("1000");
-      save();
+      saveCacheStrategyForm({ strategyType: "ttl", model: "root" });
       adaptiveRadioButton().should("be.checked");
       cy.findByLabelText(/Minimum query duration/).should("have.value", "1000");
     });
@@ -123,7 +93,7 @@ describe("scenarios > admin > performance", { tags: "@OSS" }, () => {
     it("can configure a multiplier for the default adaptive policy", () => {
       adaptiveRadioButton().click();
       cy.findByLabelText(/Multiplier/).type("3");
-      save();
+      saveCacheStrategyForm({ strategyType: "ttl", model: "root" });
       adaptiveRadioButton().should("be.checked");
       cy.findByLabelText(/Multiplier/).should("have.value", "3");
     });
@@ -132,7 +102,7 @@ describe("scenarios > admin > performance", { tags: "@OSS" }, () => {
       adaptiveRadioButton().click();
       cy.findByLabelText(/Minimum query duration/).type("1234");
       cy.findByLabelText(/Multiplier/).type("4");
-      save();
+      saveCacheStrategyForm({ strategyType: "ttl", model: "root" });
       adaptiveRadioButton().should("be.checked");
       cy.findByLabelText(/Minimum query duration/).should("have.value", "1234");
       cy.findByLabelText(/Multiplier/).should("have.value", "4");
@@ -169,11 +139,11 @@ describeEE("EE", () => {
   };
 
   it("can call cache invalidation endpoint for Sample Database", () => {
-    launchFormForItem("default policy", "No caching");
+    openStrategyFormForDatabaseOrDefaultPolicy("default policy", "No caching");
     cy.log('A "Clear cache" button is not present for the default policy');
     cy.button(/Clear cache/).should("not.exist");
 
-    launchFormForItem("Sample Database", "No caching");
+    openStrategyFormForDatabaseOrDefaultPolicy("Sample Database", "No caching");
     cy.log(
       'A "Clear cache" button is not yet present because the database does not use a cache',
     );
@@ -183,7 +153,7 @@ describeEE("EE", () => {
     durationRadioButton().click();
 
     cy.log("Save the caching strategy form");
-    save();
+    saveCacheStrategyForm({ strategyType: "duration", model: "database" });
 
     cy.log("Now there's a 'Clear cache' button. Click it");
     cy.button(/Clear cache/).click();
@@ -203,62 +173,60 @@ describeEE("EE", () => {
     const strategyAsString = strategy.toString().replace(/\//g, "");
     it(`can configure Sample Database to use a default policy of ${strategyAsString}`, () => {
       cy.log(`Set default policy to ${strategy}`);
-      launchFormForItem("default policy", "No caching");
-      radio(strategy).click();
-      save();
+      openStrategyFormForDatabaseOrDefaultPolicy(
+        "default policy",
+        "No caching",
+      );
+      cacheStrategyRadioButton(strategy).click();
+      saveCacheStrategyForm();
 
       cy.log("Open strategy form for Sample Database");
-      launchFormForItem("Sample Database", strategyAsString);
+      openStrategyFormForDatabaseOrDefaultPolicy(
+        "Sample Database",
+        strategyAsString,
+      );
 
       cy.log("Set Sample Database to Duration first");
       durationRadioButton().click();
-      save();
+      saveCacheStrategyForm({ strategyType: "duration", model: "database" });
       formLauncher("Sample Database", "currently", "Duration");
 
       cy.log("Then configure Sample Database to use the default policy");
       useDefaultRadioButton().click();
-      save({ expectedRoute: "@deleteCacheConfig" });
+      saveCacheStrategyForm({ strategyType: "inherit", model: "database" });
       formLauncher("Sample Database", "currently inheriting", strategyAsString);
     });
   });
 
   ["default policy", "Sample Database"].forEach(itemName => {
+    const model = itemName === "default policy" ? "root" : "database";
     const expectedNumberOfOptions = itemName === "default policy" ? 4 : 5;
     it(`there are ${expectedNumberOfOptions} policy options for ${itemName}`, () => {
-      launchFormForItem(itemName, "No caching");
-      form()
+      openStrategyFormForDatabaseOrDefaultPolicy(itemName, "No caching");
+      cacheStrategyForm()
         .findAllByRole("radio")
         .should("have.length", expectedNumberOfOptions);
     });
 
     it(`can set ${itemName} to Don't cache results`, () => {
-      launchFormForItem(itemName, "No caching");
+      openStrategyFormForDatabaseOrDefaultPolicy(itemName, "No caching");
       cy.log(`Set ${itemName} to Duration first`);
       durationRadioButton().click();
-      save();
+      saveCacheStrategyForm({ strategyType: "duration", model });
       formLauncher(itemName, "currently", "Duration");
 
       cy.log(`Then set ${itemName} to Don't cache results`);
       dontCacheResultsRadioButton().click();
-      // When the default policy is set to "Don't cache results", we delete the
-      // policy, but when a database's policy is set to "Don't cache results",
-      // we set its policy to "nocache". When a database's policy is set to
-      // "Use default", then we delete its policy.
-      save({
-        expectedRoute:
-          itemName === "default policy"
-            ? "@deleteCacheConfig"
-            : "@putCacheConfig",
-      });
+      saveCacheStrategyForm({ strategyType: "nocache", model });
       formLauncher(itemName, "currently", "No caching");
       checkInheritanceIfNeeded(itemName, "No caching");
     });
 
     it(`can set ${itemName} to a duration-based cache invalidation policy`, () => {
-      launchFormForItem(itemName, "No caching");
+      openStrategyFormForDatabaseOrDefaultPolicy(itemName, "No caching");
       cy.log(`Set ${itemName} to Duration`);
       durationRadioButton().click();
-      save();
+      saveCacheStrategyForm({ strategyType: "duration", model });
       cy.log(`${itemName} is now set to Duration`);
       formLauncher(itemName, "currently", "Duration");
       checkInheritanceIfNeeded(itemName, "Duration");
@@ -266,19 +234,19 @@ describeEE("EE", () => {
 
     describe("adaptive strategy", () => {
       beforeEach(() => {
-        launchFormForItem(itemName, "No caching");
+        openStrategyFormForDatabaseOrDefaultPolicy(itemName, "No caching");
         adaptiveRadioButton().click();
       });
 
       it(`can set ${itemName} to adaptive`, () => {
-        save();
+        saveCacheStrategyForm({ strategyType: "ttl", model });
         formLauncher(itemName, "currently", "Adaptive");
         checkInheritanceIfNeeded(itemName, "Adaptive");
       });
 
       it(`can configure a minimum query duration for ${itemName}'s adaptive policy`, () => {
         cy.findByLabelText(/Minimum query duration/).type("1000");
-        save();
+        saveCacheStrategyForm({ strategyType: "ttl", model });
         formLauncher(itemName, "currently", "Adaptive");
         cy.findByLabelText(/Minimum query duration/).should(
           "have.value",
@@ -288,7 +256,7 @@ describeEE("EE", () => {
 
       it(`can configure a multiplier for ${itemName}'s adaptive policy`, () => {
         cy.findByLabelText(/Multiplier/).type("3");
-        save();
+        saveCacheStrategyForm({ strategyType: "ttl", model });
         formLauncher(itemName, "currently", "Adaptive");
         cy.findByLabelText(/Multiplier/).should("have.value", "3");
       });
@@ -296,7 +264,7 @@ describeEE("EE", () => {
       it(`can configure both a minimum query duration and a multiplier for ${itemName}'s adaptive policy`, () => {
         cy.findByLabelText(/Minimum query duration/).type("1234");
         cy.findByLabelText(/Multiplier/).type("4");
-        save();
+        saveCacheStrategyForm({ strategyType: "ttl", model });
         formLauncher(itemName, "currently", "Adaptive");
         cy.findByLabelText(/Minimum query duration/).should(
           "have.value",
@@ -330,7 +298,7 @@ describeEE("EE", () => {
 
       it(`can save a new hourly schedule policy for ${itemName}`, () => {
         selectScheduleType("hourly");
-        save();
+        saveCacheStrategyForm({ strategyType: "schedule", model });
         formLauncher(itemName, "currently", "Scheduled: hourly");
       });
 
@@ -342,17 +310,12 @@ describeEE("EE", () => {
             cy.findAllByRole("searchbox").eq(1).click();
             cy.findByRole("listbox").findByText(`${time}:00`).click();
             cy.findByLabelText(amPm).next().click();
-            save();
+            saveCacheStrategyForm({ strategyType: "schedule", model });
             formLauncher("Sample Database", "currently", "Scheduled: daily");
 
             // reset for next iteration of loop
             dontCacheResultsRadioButton().click();
-            save({
-              expectedRoute:
-                itemName === "default policy"
-                  ? "@deleteCacheConfig"
-                  : "@putCacheConfig",
-            });
+            saveCacheStrategyForm({ strategyType: "nocache", model });
             scheduleRadioButton().click();
           });
         });
@@ -376,7 +339,7 @@ describeEE("EE", () => {
           const [hour, amPm] = time.split(" ");
           cy.findByRole("listbox").findByText(hour).click();
           cy.findByLabelText(amPm).next().click();
-          save();
+          saveCacheStrategyForm({ strategyType: "schedule", model });
           formLauncher(itemName, "currently", "Scheduled: weekly");
           cy.findAllByRole("searchbox").then(searchBoxes => {
             const values = Cypress._.map(
@@ -389,12 +352,7 @@ describeEE("EE", () => {
 
           // reset for next iteration of loop
           dontCacheResultsRadioButton().click();
-          save({
-            expectedRoute:
-              itemName === "default policy"
-                ? "@deleteCacheConfig"
-                : "@putCacheConfig",
-          });
+          saveCacheStrategyForm({ strategyType: "nocache", model });
           scheduleRadioButton().click();
         });
       });
diff --git a/e2e/test/scenarios/admin/performance/schedule.cy.spec.ts b/e2e/test/scenarios/admin/performance/schedule.cy.spec.ts
new file mode 100644
index 0000000000000000000000000000000000000000..93d35645efd037c442a9bfdea9ce8e37e5b85d6f
--- /dev/null
+++ b/e2e/test/scenarios/admin/performance/schedule.cy.spec.ts
@@ -0,0 +1,163 @@
+import _ from "underscore";
+
+import {
+  describeEE,
+  popover,
+  restore,
+  setTokenFeatures,
+} from "e2e/support/helpers";
+import type { ScheduleComponentType } from "metabase/components/Schedule/constants";
+import type { CacheableModel } from "metabase-types/api";
+
+import { interceptRoutes } from "./helpers/e2e-performance-helpers";
+import {
+  cacheStrategyForm,
+  getScheduleComponent,
+  openStrategyFormForDatabaseOrDefaultPolicy,
+  saveCacheStrategyForm,
+  scheduleRadioButton,
+} from "./helpers/e2e-strategy-form-helpers";
+
+/** These tests check that the schedule strategy form fields (in Admin /
+ * Performance) send the correct cron expressions to the API. They do not check
+ * that configuring the schedule strategy causes the cache to be invalidated at
+ * the appointed time. Nor do they check that the cron expression retrieved
+ * from the API is displayed in the UI. */
+describeEE("scenarios > admin > performance > schedule strategy", () => {
+  beforeEach(() => {
+    restore();
+    interceptRoutes();
+    cy.signInAsAdmin();
+    setTokenFeatures("all");
+  });
+
+  /** An object describing the values to enter in the schedule strategy configuration form. */
+  type FormValues = Partial<Record<ScheduleComponentType, string>>;
+
+  type ScheduleTestOptions = [
+    FormValues,
+    /** The cron expression we expect to be sent to the API. */
+    string,
+  ];
+
+  // prettier-ignore
+  const schedules: ScheduleTestOptions[] = [
+    [{}, "0 0 * * * ?"], // The default schedule is 'every hour, on the hour'
+
+    [{ frequency: "daily" }, "0 0 8 * * ?"], // 8 am is the default time for a daily schedule
+
+    [{ frequency: "hourly" }, "0 0 * * * ?"], // Check that switching back to hourly works
+
+    [{ frequency: "daily", time: "9:00" }, "0 0 9 * * ?"], // AM is the default choice between AM and PM
+    [{ frequency: "daily", time: "9:00", amPm: "PM" }, "0 0 21 * * ?"],
+    [{ frequency: "daily", time: "12:00", amPm: "AM" }, "0 0 0 * * ?"],
+
+    [ { frequency: "weekly", weekday: "Monday", time: "12:00", amPm: "AM" }, "0 0 0 ? * 2" ],
+    [ { frequency: "weekly", weekday: "Monday", time: "1:00", amPm: "AM" }, "0 0 1 ? * 2" ],
+
+    [ { frequency: "weekly", weekday: "Tuesday", time: "2:00", amPm: "AM" }, "0 0 2 ? * 3" ],
+    [ { frequency: "weekly", weekday: "Tuesday", time: "3:00", amPm: "AM" }, "0 0 3 ? * 3" ],
+
+    [ { frequency: "weekly", weekday: "Wednesday", time: "4:00", amPm: "AM" }, "0 0 4 ? * 4" ],
+    [ { frequency: "weekly", weekday: "Wednesday", time: "5:00", amPm: "AM" }, "0 0 5 ? * 4" ],
+
+    [ { frequency: "weekly", weekday: "Thursday", time: "6:00", amPm: "AM" }, "0 0 6 ? * 5" ],
+    [ { frequency: "weekly", weekday: "Thursday", time: "7:00", amPm: "AM" }, "0 0 7 ? * 5" ],
+
+    [ { frequency: "weekly", weekday: "Friday", time: "8:00", amPm: "AM" }, "0 0 8 ? * 6" ],
+    [ { frequency: "weekly", weekday: "Friday", time: "9:00", amPm: "AM" }, "0 0 9 ? * 6" ],
+
+    [ { frequency: "weekly", weekday: "Saturday", time: "10:00", amPm: "AM" }, "0 0 10 ? * 7" ],
+    [ { frequency: "weekly", weekday: "Saturday", time: "11:00", amPm: "AM" }, "0 0 11 ? * 7" ],
+
+    [ { frequency: "weekly", weekday: "Sunday", time: "12:00", amPm: "PM" }, "0 0 12 ? * 1" ],
+    [ { frequency: "weekly", weekday: "Sunday", time: "1:00", amPm: "PM" }, "0 0 13 ? * 1" ],
+
+    [ { frequency: "monthly", frame: "first", weekdayOfMonth: "Sunday", time: "12:00", amPm: "AM" }, "0 0 0 ? * 1#1" ],
+    [ { frequency: "monthly", frame: "first", weekdayOfMonth: "Monday", time: "2:00", amPm: "AM" }, "0 0 2 ? * 2#1" ],
+
+    [ { frequency: "monthly", frame: "last", weekdayOfMonth: "Tuesday", time: "12:00", amPm: "AM" }, "0 0 0 ? * 3L" ],
+    [ { frequency: "monthly", frame: "last", weekdayOfMonth: "Wednesday", time: "12:00", amPm: "AM" }, "0 0 0 ? * 4L" ],
+
+    [ { frequency: "monthly", frame: "15th", time: "12:00", amPm: "AM" }, "0 0 0 15 * ?" ],
+    [ { frequency: "monthly", frame: "15th", time: "11:00", amPm: "PM" }, "0 0 23 15 * ?" ],
+
+  ];
+
+  (["root", "database"] as CacheableModel[]).forEach(model => {
+    schedules.forEach(([schedule, cronExpression]) => {
+      it(`can set on ${model}: ${
+        Object.values(schedule).join(" ") || "default values"
+      }, yielding a cron of ${cronExpression}`, () => {
+        openStrategyFormForDatabaseOrDefaultPolicy(
+          model === "root" ? "default policy" : "Sample Database",
+          "No caching",
+        );
+        scheduleRadioButton().click();
+        _.pairs(schedule).forEach(([componentType, optionToClick]) => {
+          if (componentType === "amPm") {
+            // AM/PM is a segmented control, not a select
+            cacheStrategyForm()
+              .findByLabelText("AM/PM")
+              .findByText(optionToClick)
+              .click();
+          } else {
+            getScheduleComponent(componentType).click();
+
+            popover().within(() => {
+              cy.findByText(optionToClick).click();
+            });
+          }
+        });
+        saveCacheStrategyForm().then(xhr => {
+          const { body } = xhr.request;
+          expect(body.model).to.eq(model);
+          expect(body.strategy.schedule).to.eq(cronExpression);
+        });
+        cy.log(
+          "Ensure there are no unexpected components among the schedule strategy form fields",
+        );
+        const expectedFieldLabels: string[] = [];
+        expectedFieldLabels.push("Frequency");
+        switch (schedule.frequency) {
+          case undefined:
+            // This means the frequency was not changed; the default frequency (hourly) is selected
+            break;
+          case "hourly":
+            break;
+          case "daily":
+            expectedFieldLabels.push("Time", "AM/PM", "Your Metabase timezone");
+            break;
+          case "weekly":
+            expectedFieldLabels.push(
+              "Day of the week",
+              "Time",
+              "AM/PM",
+              "Your Metabase timezone",
+            );
+            break;
+          case "monthly":
+            expectedFieldLabels.push("First, 15th, or last of the month");
+            if (schedule.frame !== "15th") {
+              expectedFieldLabels.push("Day of the month");
+            }
+            expectedFieldLabels.push("Time", "AM/PM", "Your Metabase timezone");
+            break;
+          default:
+            throw new Error(
+              `Unexpected schedule frequency: ${schedule.frequency}`,
+            );
+        }
+        cacheStrategyForm()
+          .findByLabelText("Describe how often the cache should be invalidated")
+          .find("[aria-label]")
+          .then($labels => {
+            const labels = $labels
+              .get()
+              .map($el => $el.getAttribute("aria-label"));
+            expect(labels).to.deep.equal(expectedFieldLabels);
+          });
+      });
+    });
+  });
+});
diff --git a/e2e/test/scenarios/dashboard/dashboard.cy.spec.js b/e2e/test/scenarios/dashboard/dashboard.cy.spec.js
index d69b1db8ea865017ca99287c0dc75070bce692dd..b9bbdcf134cf48d5fea29129d50d79bc01f78407 100644
--- a/e2e/test/scenarios/dashboard/dashboard.cy.spec.js
+++ b/e2e/test/scenarios/dashboard/dashboard.cy.spec.js
@@ -58,6 +58,13 @@ import {
   createMockVirtualDashCard,
 } from "metabase-types/api/mocks";
 
+import { interceptRoutes as interceptPerformanceRoutes } from "../admin/performance/helpers/e2e-performance-helpers";
+import {
+  adaptiveRadioButton,
+  durationRadioButton,
+  openSidebarCacheStrategyForm,
+} from "../admin/performance/helpers/e2e-strategy-form-helpers";
+
 const { ORDERS, ORDERS_ID, PRODUCTS, PEOPLE, PEOPLE_ID } = SAMPLE_DATABASE;
 
 describe("scenarios > dashboard", () => {
@@ -1254,40 +1261,29 @@ describeEE("scenarios > dashboard > caching", () => {
    * It's in the Cypress describe block labeled "scenarios > question > caching"
    */
   it("can configure cache for a dashboard, on an enterprise instance", () => {
-    cy.intercept("PUT", "/api/cache").as("putCacheConfig");
+    interceptPerformanceRoutes();
     visitDashboard(ORDERS_DASHBOARD_ID);
 
-    toggleDashboardInfoSidebar();
+    openSidebarCacheStrategyForm();
 
     rightSidebar().within(() => {
-      cy.findByText(/Caching policy/).within(() => {
-        cy.findByText(/Use default/).click();
-      });
-      cy.findByRole("heading", { name: /Caching settings/ }).click();
-      cy.findByRole("radio", { name: /Duration/ }).click();
+      cy.findByRole("heading", { name: /Caching settings/ }).should(
+        "be.visible",
+      );
+      durationRadioButton().click();
       cy.findByLabelText("Cache results for this many hours").type("48");
       cy.findByRole("button", { name: /Save/ }).click();
       cy.wait("@putCacheConfig");
-      cy.findByText(/Caching policy/).within(() => {
-        cy.log(
-          "Check that the newly chosen cache invalidation policy - Duration - is now visible in the sidebar",
-        );
-        const durationButton = cy.findByRole("button", { name: /Duration/ });
-        durationButton.should("be.visible");
-        cy.log("Open the cache invalidation policy configuration form again");
-        durationButton.click();
-      });
-      cy.findByRole("radio", { name: /Adaptive/ }).click();
+      cy.log(
+        "Check that the newly chosen cache invalidation policy - Duration - is now visible in the sidebar",
+      );
+      cy.findByLabelText(/Caching policy/).should("contain", "Duration");
+      cy.findByLabelText(/Caching policy/).click();
+      adaptiveRadioButton().click();
       cy.findByLabelText(/Minimum query duration/).type("999");
       cy.findByRole("button", { name: /Save/ }).click();
       cy.wait("@putCacheConfig");
-      cy.findByText(/Caching policy/).within(() => {
-        cy.log(
-          "Check that the newly chosen cache invalidation policy - Adaptive - is now visible in the sidebar",
-        );
-        const policyToken = cy.findByRole("button", { name: /Adaptive/ });
-        policyToken.should("be.visible");
-      });
+      cy.findByLabelText(/Caching policy/).should("contain", "Adaptive");
     });
   });
 });
diff --git a/e2e/test/scenarios/question/caching.cy.spec.js b/e2e/test/scenarios/question/caching.cy.spec.js
index a6c6e5e7c2261aa65ae16ea46ca77316bb1361d0..0d37c4f4e776e6ef5acabb1d05a2e39332855d33 100644
--- a/e2e/test/scenarios/question/caching.cy.spec.js
+++ b/e2e/test/scenarios/question/caching.cy.spec.js
@@ -1,13 +1,19 @@
 import { ORDERS_QUESTION_ID } from "e2e/support/cypress_sample_instance_data";
 import {
   describeEE,
-  questionInfoButton,
   restore,
   rightSidebar,
   setTokenFeatures,
   visitQuestion,
 } from "e2e/support/helpers";
 
+import { interceptRoutes as interceptPerformanceRoutes } from "../admin/performance/helpers/e2e-performance-helpers";
+import {
+  adaptiveRadioButton,
+  durationRadioButton,
+  openSidebarCacheStrategyForm,
+} from "../admin/performance/helpers/e2e-strategy-form-helpers";
+
 describeEE("scenarios > question > caching", () => {
   beforeEach(() => {
     restore();
@@ -20,40 +26,32 @@ describeEE("scenarios > question > caching", () => {
    * It's in the Cypress describe block labeled "scenarios > dashboard > caching"
    */
   it("can configure cache for a question, on an enterprise instance", () => {
-    cy.intercept("PUT", "/api/cache").as("putCacheConfig");
+    interceptPerformanceRoutes();
     visitQuestion(ORDERS_QUESTION_ID);
 
-    questionInfoButton().click();
+    openSidebarCacheStrategyForm();
 
     rightSidebar().within(() => {
-      cy.findByText(/Caching policy/).within(() => {
-        cy.findByRole("button", { name: /Use default/ }).click();
-      });
-      cy.findByRole("heading", { name: /Caching settings/ }).click();
-      cy.findByRole("radio", { name: /Duration/ }).click();
+      cy.findByRole("heading", { name: /Caching settings/ }).should(
+        "be.visible",
+      );
+      durationRadioButton().click();
       cy.findByLabelText("Cache results for this many hours").type("48");
       cy.findByRole("button", { name: /Save/ }).click();
       cy.wait("@putCacheConfig");
-      cy.findByText(/Caching policy/).within(() => {
-        cy.log(
-          "Check that the newly chosen cache invalidation policy - Duration - is now visible in the sidebar",
-        );
-        const durationButton = cy.findByRole("button", { name: /Duration/ });
-        durationButton.should("be.visible");
-        cy.log("Open the cache invalidation policy configuration form again");
-        durationButton.click();
-      });
-      cy.findByRole("radio", { name: /Adaptive/ }).click();
+      cy.log(
+        "Check that the newly chosen cache invalidation policy - Duration - is now visible in the sidebar",
+      );
+      cy.findByLabelText(/Caching policy/).should("contain", "Duration");
+      cy.findByLabelText(/Caching policy/).click();
+      adaptiveRadioButton().click();
       cy.findByLabelText(/Minimum query duration/).type("999");
       cy.findByRole("button", { name: /Save/ }).click();
       cy.wait("@putCacheConfig");
-      cy.findByText(/Caching policy/).within(() => {
-        cy.log(
-          "Check that the newly chosen cache invalidation policy - Adaptive - is now visible in the sidebar",
-        );
-        const policyToken = cy.findByRole("button", { name: /Adaptive/ });
-        policyToken.should("be.visible");
-      });
+      cy.log(
+        "Check that the newly chosen cache invalidation policy - Adaptive - is now visible in the sidebar",
+      );
+      cy.findByLabelText(/Caching policy/).should("contain", "Adaptive");
     });
   });
 });
diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/SidebarCacheSection.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/SidebarCacheSection.tsx
index d85e0343e22f34d0f93c5b607397f54a2c855b22..8620d94ef9d81ece12c8732da300c213a3e6a737 100644
--- a/enterprise/frontend/src/metabase-enterprise/caching/components/SidebarCacheSection.tsx
+++ b/enterprise/frontend/src/metabase-enterprise/caching/components/SidebarCacheSection.tsx
@@ -33,12 +33,17 @@ export const SidebarCacheSection = ({
 
   const shortStrategyLabel =
     getShortStrategyLabel(savedStrategy, model) || t`Use default`;
+  const labelId = "question-caching-policy-label";
 
   return (
     <DelayedLoadingAndErrorWrapper delay={0} loading={loading} error={error}>
       <Flex align="center" justify="space-between">
-        {t`Caching policy`}
-        <FormLauncher role="button" onClick={() => setPage("caching")}>
+        <span id={labelId}>{t`Caching policy`}</span>
+        <FormLauncher
+          role="button"
+          onClick={() => setPage("caching")}
+          aria-labelledby={labelId}
+        >
           {shortStrategyLabel}
         </FormLauncher>
       </Flex>
diff --git a/frontend/src/metabase/admin/performance/components/StrategyForm.tsx b/frontend/src/metabase/admin/performance/components/StrategyForm.tsx
index faf9e01b16566db796d682e22dc3bdd8df0bbbfb..89a5616007f11fb425602ee2e24a66d2e8e429f5 100644
--- a/frontend/src/metabase/admin/performance/components/StrategyForm.tsx
+++ b/frontend/src/metabase/admin/performance/components/StrategyForm.tsx
@@ -344,6 +344,7 @@ const ScheduleStrategyFormFields = () => {
       onScheduleChange={onScheduleChange}
       verb={c("A verb in the imperative mood").t`Invalidate`}
       timezone={timezone}
+      aria-label={t`Describe how often the cache should be invalidated`}
     />
   );
 };
diff --git a/frontend/src/metabase/components/Schedule/Schedule.tsx b/frontend/src/metabase/components/Schedule/Schedule.tsx
index f6ccd824791e8cbce6179e7db665d85d8ca812d9..fe48d0d1e4c2366af3e65dc15139749deff9a6b0 100644
--- a/frontend/src/metabase/components/Schedule/Schedule.tsx
+++ b/frontend/src/metabase/components/Schedule/Schedule.tsx
@@ -1,21 +1,24 @@
-import { type ReactNode, useCallback } from "react";
+import { type ReactNode, useCallback, type HTMLAttributes } from "react";
 import { c } from "ttag";
 
-import { capitalize } from "metabase/lib/formatting/strings";
 import { removeNullAndUndefinedValues } from "metabase/lib/types";
-import { Box } from "metabase/ui";
+import { Box, type BoxProps } from "metabase/ui";
 import type { ScheduleSettings, ScheduleType } from "metabase-types/api";
 
 import {
-  AutoWidthSelect,
   SelectFrame,
   SelectMinute,
   SelectTime,
   SelectWeekday,
   SelectWeekdayOfMonth,
+  SelectFrequency,
 } from "./components";
 import { defaultDay, defaultHour, getScheduleStrings } from "./constants";
-import type { ScheduleChangeProp, UpdateSchedule } from "./types";
+import type {
+  ScheduleChangeProp,
+  UpdateSchedule,
+  ScheduleDefaults,
+} from "./types";
 import { fillScheduleTemplate, getLongestSelectLabel } from "./utils";
 
 type ScheduleProperty = keyof ScheduleSettings;
@@ -33,7 +36,7 @@ export interface ScheduleProps {
   minutesOnHourPicker?: boolean;
 }
 
-const defaults: Record<string, Partial<ScheduleSettings>> = {
+const defaults: ScheduleDefaults = {
   hourly: {
     schedule_day: null,
     schedule_frame: null,
@@ -49,11 +52,13 @@ const defaults: Record<string, Partial<ScheduleSettings>> = {
   weekly: {
     schedule_day: defaultDay,
     schedule_frame: null,
+    schedule_hour: defaultHour,
     schedule_minute: 0,
   },
   monthly: {
-    schedule_frame: "first",
     schedule_day: defaultDay,
+    schedule_frame: "first",
+    schedule_hour: defaultHour,
     schedule_minute: 0,
   },
 };
@@ -65,6 +70,7 @@ export const Schedule = ({
   verb,
   minutesOnHourPicker,
   onScheduleChange,
+  ...boxProps
 }: {
   schedule: ScheduleSettings;
   scheduleOptions: ScheduleType[];
@@ -75,7 +81,8 @@ export const Schedule = ({
     nextSchedule: ScheduleSettings,
     change: ScheduleChangeProp,
   ) => void;
-}) => {
+} & BoxProps &
+  HTMLAttributes<HTMLDivElement>) => {
   const updateSchedule: UpdateSchedule = useCallback(
     (field: ScheduleProperty, value: ScheduleSettings[typeof field]) => {
       let newSchedule: ScheduleSettings = {
@@ -117,6 +124,7 @@ export const Schedule = ({
         gap: ".5rem",
         rowGap: ".35rem",
       }}
+      {...boxProps}
     >
       {renderSchedule({
         fillScheduleTemplate,
@@ -263,31 +271,3 @@ const renderSchedule = ({
     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 { scheduleOptionNames } = getScheduleStrings();
-
-  const scheduleTypeOptions = scheduleOptions.map(option => ({
-    label: scheduleOptionNames[option] || capitalize(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
index 30f39f1f38ea434bc9b52e7cfb4cfa66076a77f3..bf115d4715f87e4cb1c4768997639583e93d3017 100644
--- a/frontend/src/metabase/components/Schedule/components.tsx
+++ b/frontend/src/metabase/components/Schedule/components.tsx
@@ -5,6 +5,7 @@ import {
   hourTo24HourFormat,
   hourToTwelveHourFormat,
 } from "metabase/admin/performance/utils";
+import { capitalize } from "metabase/lib/formatting/strings";
 import { useSelector } from "metabase/lib/redux";
 import { has24HourModeSetting } from "metabase/lib/time";
 import { getSetting } from "metabase/selectors/settings";
@@ -16,6 +17,7 @@ import type {
   ScheduleDayType,
   ScheduleFrameType,
   ScheduleSettings,
+  ScheduleType,
 } from "metabase-types/api";
 
 import {
@@ -24,6 +26,7 @@ import {
   getHours,
   getScheduleStrings,
   minutes,
+  getScheduleComponentLabel,
 } from "./constants";
 import type { UpdateSchedule } from "./types";
 import { getLongestSelectLabel, measureTextWidthSafely } from "./utils";
@@ -34,11 +37,46 @@ export type SelectFrameProps = {
   frames?: { label: string; value: ScheduleFrameType }[];
 };
 
+/** A Select that changes the schedule frequency (e.g., daily, hourly, monthly, etc.),
+ * also known as the schedule 'type'. */
+export const SelectFrequency = ({
+  scheduleType,
+  updateSchedule,
+  scheduleOptions,
+}: {
+  scheduleType?: ScheduleType | null;
+  updateSchedule: UpdateSchedule;
+  scheduleOptions: ScheduleType[];
+}) => {
+  const { scheduleOptionNames } = getScheduleStrings();
+
+  const scheduleTypeOptions = useMemo(
+    () =>
+      scheduleOptions.map(option => ({
+        label: scheduleOptionNames[option] || capitalize(option),
+        value: option,
+      })),
+    [scheduleOptions, scheduleOptionNames],
+  );
+
+  const label = useMemo(() => getScheduleComponentLabel("frequency"), []);
+  return (
+    <AutoWidthSelect
+      display="flex"
+      value={scheduleType}
+      onChange={(value: ScheduleType) => updateSchedule("schedule_type", value)}
+      data={scheduleTypeOptions}
+      aria-label={label}
+    />
+  );
+};
+
 export const SelectFrame = ({
   schedule,
   updateSchedule,
   frames = getScheduleStrings().frames,
 }: SelectFrameProps) => {
+  const label = useMemo(() => getScheduleComponentLabel("frame"), []);
   return (
     <AutoWidthSelect
       value={schedule.schedule_frame}
@@ -46,6 +84,7 @@ export const SelectFrame = ({
         updateSchedule("schedule_frame", value)
       }
       data={frames}
+      aria-label={label}
     />
   );
 };
@@ -60,7 +99,6 @@ export const SelectTime = ({
   timezone?: string | null;
 }) => {
   const { amAndPM } = getScheduleStrings();
-  const applicationName = useSelector(getApplicationName);
   const isClock12Hour = !has24HourModeSetting();
   const hourIn24HourFormat =
     schedule.schedule_hour !== undefined &&
@@ -74,6 +112,10 @@ export const SelectTime = ({
   const amPm = hourIn24HourFormat >= 12 ? 1 : 0;
   const hourIndex = isClock12Hour && hour === 12 ? 0 : hour;
   const value = hourIndex === 0 && isClock12Hour ? "12" : hourIndex.toString();
+  const timeSelectLabel = useMemo(() => getScheduleComponentLabel("time"), []);
+  const amPmControlLabel = useMemo(() => getScheduleComponentLabel("amPm"), []);
+  const applicationName = useSelector(getApplicationName);
+  const timezoneTooltipText = t`Your ${applicationName} timezone`;
   return (
     <Group spacing={isClock12Hour ? "xs" : "sm"} style={{ rowGap: ".5rem" }}>
       {/* Select the hour */}
@@ -87,6 +129,7 @@ export const SelectTime = ({
             isClock12Hour ? hourTo24HourFormat(num, amPm) : num,
           );
         }}
+        aria-label={timeSelectLabel}
       />
       {/* Choose between AM and PM */}
       <Group spacing="sm">
@@ -101,11 +144,17 @@ export const SelectTime = ({
               )
             }
             data={amAndPM}
+            aria-label={amPmControlLabel}
           />
         )}
         {timezone && (
-          <Tooltip label={t`Your ${applicationName} timezone`}>
-            <Box tabIndex={0}>{timezone}</Box>
+          <Tooltip label={timezoneTooltipText}>
+            <Box
+              aria-label={timezoneTooltipText}
+              tabIndex={0} // Ensure tooltip can be triggered by the keyboard
+            >
+              {timezone}
+            </Box>
           </Tooltip>
         )}
       </Group>
@@ -123,6 +172,7 @@ export const SelectWeekday = ({
   updateSchedule,
 }: SelectWeekdayProps) => {
   const { weekdays } = getScheduleStrings();
+  const label = useMemo(() => getScheduleComponentLabel("weekday"), []);
   return (
     <AutoWidthSelect
       value={schedule.schedule_day}
@@ -130,6 +180,7 @@ export const SelectWeekday = ({
         updateSchedule("schedule_day", value)
       }
       data={weekdays}
+      aria-label={label}
     />
   );
 };
@@ -143,13 +194,15 @@ export type SelectWeekdayOfMonthProps = {
   )[];
 };
 
-/** Selects the weekday of the month, e.g. the first Monday of the month
-  "First" is selected via SelectFrame. This component provides the weekday */
+/** Selects the weekday of the month, such as the first Monday of the month or the last Tuesday of the month.
+  (The SelectFrame component offers a choice between 'first', '15th' and 'last'.
+  This component offers a choice of weekday.) */
 export const SelectWeekdayOfMonth = ({
   schedule,
   updateSchedule,
   weekdayOfMonthOptions = getScheduleStrings().weekdayOfMonthOptions,
 }: SelectWeekdayOfMonthProps) => {
+  const label = useMemo(() => getScheduleComponentLabel("weekdayOfMonth"), []);
   return (
     <AutoWidthSelect
       value={schedule.schedule_day || "calendar-day"}
@@ -157,6 +210,7 @@ export const SelectWeekdayOfMonth = ({
         updateSchedule("schedule_day", value === "calendar-day" ? null : value)
       }
       data={weekdayOfMonthOptions}
+      aria-label={label}
     />
   );
 };
@@ -171,6 +225,7 @@ export const SelectMinute = ({
   const minuteOfHour = isNaN(schedule.schedule_minute as number)
     ? 0
     : schedule.schedule_minute;
+  const label = useMemo(() => getScheduleComponentLabel("minute"), []);
   return (
     <AutoWidthSelect
       value={(minuteOfHour || 0).toString()}
@@ -178,6 +233,7 @@ export const SelectMinute = ({
       onChange={(value: string) =>
         updateSchedule("schedule_minute", Number(value))
       }
+      aria-label={label}
     />
   );
 };
diff --git a/frontend/src/metabase/components/Schedule/constants.ts b/frontend/src/metabase/components/Schedule/constants.ts
index 01c1d11143e95353de8d697c6cd057c599bab9df..4c01d48a95b2ed6df2bfd9f42c75262cb7de6104 100644
--- a/frontend/src/metabase/components/Schedule/constants.ts
+++ b/frontend/src/metabase/components/Schedule/constants.ts
@@ -153,3 +153,27 @@ export enum Cron {
   NoSpecificValue = "?",
   NoSpecificValue_Escaped = "\\?",
 }
+
+export type ScheduleComponentType =
+  | "frequency"
+  | "frame"
+  | "weekdayOfMonth"
+  | "weekday"
+  | "time"
+  | "amPm"
+  | "minute";
+
+export const getScheduleComponentLabel = (
+  componentType: ScheduleComponentType,
+) => {
+  const map: Record<ScheduleComponentType, string> = {
+    frequency: t`Frequency`,
+    frame: t`First, 15th, or last of the month`,
+    weekdayOfMonth: t`Day of the month`,
+    weekday: t`Day of the week`,
+    time: t`Time`,
+    amPm: t`AM/PM`,
+    minute: t`Minute`,
+  };
+  return map[componentType];
+};
diff --git a/frontend/src/metabase/components/Schedule/types.ts b/frontend/src/metabase/components/Schedule/types.ts
index 9196ae660a48ac6bc3ef6fddf691495747322872..02f1a7e8c35284be5dcb9521a981c22d4466766f 100644
--- a/frontend/src/metabase/components/Schedule/types.ts
+++ b/frontend/src/metabase/components/Schedule/types.ts
@@ -1,4 +1,4 @@
-import type { ScheduleSettings } from "metabase-types/api";
+import type { ScheduleSettings, ScheduleType } from "metabase-types/api";
 
 type ScheduleProperty = keyof ScheduleSettings;
 export type ScheduleChangeProp = { name: ScheduleProperty; value: unknown };
@@ -7,3 +7,11 @@ export type UpdateSchedule = (
   field: ScheduleProperty,
   value: ScheduleSettings[typeof field],
 ) => void;
+
+/** A default schedule should assign a value or null to
+ * each property of ScheduleSettings
+ * */
+export type ScheduleDefaults = Record<
+  ScheduleType,
+  Required<Omit<ScheduleSettings, "schedule_type">>
+>;