From 3305c815b0bbb04706b53a135e299826f010a348 Mon Sep 17 00:00:00 2001
From: Kamil Mielnik <kamil@kamilmielnik.com>
Date: Tue, 4 Jun 2024 15:15:47 +0700
Subject: [PATCH] Previous period comparison shortcut in notebook mode (#43220)

* Sort functions

* Add boilerplate for aggregateOffset and tests

* Simplify assertion

* Make basic case work

* Handle name generation

* Do not require "lib/uuid" attribute to be present since MLv2 will normalize it under the hood anyway

* Fix typing

* Group tests

* Refactor offsetClause to return a new clause instead of a new query
- Move offsetClause to expressions.ts
- Add assertion

* Revert "Sort functions"

This reverts commit ab9ce2b24ea6bdad5ff7e9bed8ef38d4b5923f00.

* Move tests

* Handle names dynamically

* Shorten a test

* Update comment

* Add TODO

* Update expression types

* Add diffOffsetClause & percentDiffOffsetClause

* Add a test for diffOffsetClause

* Add TODOs

* Add tests for diffOffsetClause and percentDiffOffsetClause

* Unwrap tests

* Add skeleton for new tests

* Refactor first offsetClause tests

* Add tests for names

* Fix case of non-datetime columns

* Handle a case with offset < 1 and no breakouts

* Handle a case with offset < 1 and breakouts on non-datetime column

* Handle a case with offset < 1 and breakouts binned datetime column

* Handle a case with offset < 1 and breakout on non-binned datetime column

* Refactor

* Refactor

* Remove TODO

* Add tests for diffOffsetClause

* Add tests for percentDiffOffsetClause

* Move offset stuff to offset.ts and offset.unit.spec.ts

* Use template string for prefixes

* Use breakoutColumn + isDate to check column type

* Refactor

* Fix error message

* Add boilerplate for CompareAggregations

* Fix title

* Render aggregations list

* Style AccordionList

* Sort props

* Fix bucket name

* Use displayName instead shortName

* Support parseValue prop in NumberInput

* Add period input accepting integers only

* Accept non-negative values only

* Do not accept zeros

* Add state

* Add submit button

* Export offset functions via Lib

* Make it possible to skip rightSection

* Add column picker

* Map offset user input to api input (negative value)

* Add label

* Fix crash

* Extract shouldCreate

* Make onSelect in AggregationPicker support multiple values

* Extract ReferenceAggregationPicker

* Extract ColumnPicker

* Extract getAggregations

* Rename

* Add custom items

* Refactor item component

* Extract OffsetInput

* Remove unused data-testid

* Style OffsetInput

* Generate titles according to specs

* Generate label

* Generate help

* Extract utils

* Use different width for the 1st step

* Format code

* Use MultiSelect directly

* Avoid custom parseValue

* Revert MultiaAutocomplete changes

* Improve typing in describeTemporalInterval and
 describeRelativeDatetime

* Use describeTemporalUnit to pluralize

* Use interface

* Avoid setting value as DOM attribute

* Fix test

* Add onAdd prop to AggregationPicker and revert the change to have onSelect pass multiple aggregations

* Reduce number of props

* Render checkboxes in custom items

* Introduce and use --mb-color-brand-lighter

* Avoid !important

* Remove redundant prop

* Fix warning about isSelected being used as DOM attribute

* Fix positioning in case dir attribute is not present in any parent component

* Add type attribute to all ListItems
---
 .../components/private/SdkContentWrapper.tsx  |   4 +-
 frontend/src/metabase-lib/index.ts            |   1 +
 frontend/src/metabase-lib/offset.ts           |  10 +-
 frontend/src/metabase-lib/temporal_bucket.ts  |   4 +-
 .../AggregationPicker/AggregationPicker.tsx   |  99 +++++++++++++--
 .../AggregationPicker.unit.spec.tsx           |   2 +
 .../CompareAggregations.tsx                   | 114 ++++++++++++++++++
 .../ColumnPicker/ColumnPicker.module.css      |   3 +
 .../components/ColumnPicker/ColumnPicker.tsx  |  95 +++++++++++++++
 .../components/ColumnPicker/index.ts          |   1 +
 .../OffsetInput/OffsetInput.module.css        |  12 ++
 .../components/OffsetInput/OffsetInput.tsx    |  52 ++++++++
 .../components/OffsetInput/index.ts           |   1 +
 .../components/OffsetInput/utils.ts           |  58 +++++++++
 .../ReferenceAggregationPicker.module.css     |   3 +
 .../ReferenceAggregationPicker.tsx            |  61 ++++++++++
 .../ReferenceAggregationPicker/index.ts       |   1 +
 .../CompareAggregations/components/index.ts   |   3 +
 .../components/CompareAggregations/index.ts   |   2 +
 .../components/CompareAggregations/types.ts   |   1 +
 .../components/CompareAggregations/utils.ts   |  95 +++++++++++++++
 .../steps/AggregateStep/AggregateStep.tsx     |  16 ++-
 .../AddAggregationButton.tsx                  |  10 +-
 .../AggregationItem/AggregationItem.tsx       |   3 +
 .../SummarizeSidebar/SummarizeSidebar.tsx     |  12 +-
 .../containers/GlobalStyles/GlobalStyles.tsx  |   3 +-
 26 files changed, 631 insertions(+), 35 deletions(-)
 create mode 100644 frontend/src/metabase/query_builder/components/CompareAggregations/CompareAggregations.tsx
 create mode 100644 frontend/src/metabase/query_builder/components/CompareAggregations/components/ColumnPicker/ColumnPicker.module.css
 create mode 100644 frontend/src/metabase/query_builder/components/CompareAggregations/components/ColumnPicker/ColumnPicker.tsx
 create mode 100644 frontend/src/metabase/query_builder/components/CompareAggregations/components/ColumnPicker/index.ts
 create mode 100644 frontend/src/metabase/query_builder/components/CompareAggregations/components/OffsetInput/OffsetInput.module.css
 create mode 100644 frontend/src/metabase/query_builder/components/CompareAggregations/components/OffsetInput/OffsetInput.tsx
 create mode 100644 frontend/src/metabase/query_builder/components/CompareAggregations/components/OffsetInput/index.ts
 create mode 100644 frontend/src/metabase/query_builder/components/CompareAggregations/components/OffsetInput/utils.ts
 create mode 100644 frontend/src/metabase/query_builder/components/CompareAggregations/components/ReferenceAggregationPicker/ReferenceAggregationPicker.module.css
 create mode 100644 frontend/src/metabase/query_builder/components/CompareAggregations/components/ReferenceAggregationPicker/ReferenceAggregationPicker.tsx
 create mode 100644 frontend/src/metabase/query_builder/components/CompareAggregations/components/ReferenceAggregationPicker/index.ts
 create mode 100644 frontend/src/metabase/query_builder/components/CompareAggregations/components/index.ts
 create mode 100644 frontend/src/metabase/query_builder/components/CompareAggregations/index.ts
 create mode 100644 frontend/src/metabase/query_builder/components/CompareAggregations/types.ts
 create mode 100644 frontend/src/metabase/query_builder/components/CompareAggregations/utils.ts

diff --git a/enterprise/frontend/src/embedding-sdk/components/private/SdkContentWrapper.tsx b/enterprise/frontend/src/embedding-sdk/components/private/SdkContentWrapper.tsx
index 9e1c9b7e206..809c574a869 100644
--- a/enterprise/frontend/src/embedding-sdk/components/private/SdkContentWrapper.tsx
+++ b/enterprise/frontend/src/embedding-sdk/components/private/SdkContentWrapper.tsx
@@ -4,7 +4,7 @@ import type { HTMLAttributes } from "react";
 
 import { getRootStyle } from "metabase/css/core/base.styled";
 import { defaultFontFiles } from "metabase/css/core/fonts.styled";
-import { alpha } from "metabase/lib/colors";
+import { alpha, lighten } from "metabase/lib/colors";
 import { useSelector } from "metabase/lib/redux";
 import { aceEditorStyles } from "metabase/query_builder/components/NativeQueryEditor/NativeQueryEditor.styled";
 import { getFontFiles } from "metabase/styled-components/selectors";
@@ -37,6 +37,8 @@ const SdkContentWrapperInner = styled.div<
   --mb-color-bg-light: ${({ theme }) => theme.fn.themeColor("bg-light")};
   --mb-color-bg-dark: ${({ theme }) => theme.fn.themeColor("bg-dark")};
   --mb-color-brand: ${({ theme }) => theme.fn.themeColor("brand")};
+  --mb-color-brand-lighter: ${({ theme }) =>
+    lighten(theme.fn.themeColor("brand"), 0.598)};
   --mb-color-brand-alpha-04: ${({ theme }) =>
     alpha(theme.fn.themeColor("brand"), 0.04)};
   --mb-color-brand-alpha-88: ${({ theme }) =>
diff --git a/frontend/src/metabase-lib/index.ts b/frontend/src/metabase-lib/index.ts
index 1cc3d436783..49abee69e7a 100644
--- a/frontend/src/metabase-lib/index.ts
+++ b/frontend/src/metabase-lib/index.ts
@@ -17,6 +17,7 @@ export * from "./limit";
 export * from "./metadata";
 export * from "./metrics";
 export * from "./native";
+export * from "./offset";
 export * from "./order_by";
 export * from "./query";
 export * from "./segments";
diff --git a/frontend/src/metabase-lib/offset.ts b/frontend/src/metabase-lib/offset.ts
index cfceaa288b2..860905ff9cf 100644
--- a/frontend/src/metabase-lib/offset.ts
+++ b/frontend/src/metabase-lib/offset.ts
@@ -1,11 +1,9 @@
 import { t } from "ttag";
 
-import { inflect } from "metabase/lib/formatting";
-
 import { breakoutColumn, breakouts } from "./breakout";
 import { isDate } from "./column_types";
 import { expressionClause, withExpressionName } from "./expression";
-import { displayInfo } from "./metadata";
+import { describeTemporalUnit, displayInfo } from "./metadata";
 import { temporalBucket } from "./temporal_bucket";
 import type { AggregationClause, ExpressionClause, Query } from "./types";
 
@@ -103,8 +101,10 @@ function getOffsetClauseName(
   }
 
   const bucketInfo = displayInfo(query, stageIndex, bucket);
-  const bucketName = bucketInfo.displayName.toLowerCase();
-  const period = inflect(bucketName, absoluteOffset);
+  const period = describeTemporalUnit(
+    bucketInfo.shortName,
+    absoluteOffset,
+  ).toLowerCase();
 
   return absoluteOffset === 1
     ? t`${displayName} (${prefix}previous ${period})`
diff --git a/frontend/src/metabase-lib/temporal_bucket.ts b/frontend/src/metabase-lib/temporal_bucket.ts
index 2728758ccd9..8c54a0bb3e1 100644
--- a/frontend/src/metabase-lib/temporal_bucket.ts
+++ b/frontend/src/metabase-lib/temporal_bucket.ts
@@ -52,14 +52,14 @@ type IntervalAmount = number | "current" | "next" | "last";
 
 export function describeTemporalInterval(
   n: IntervalAmount,
-  unit?: string,
+  unit?: BucketName,
 ): string {
   return ML.describe_temporal_interval(n, unit);
 }
 
 export function describeRelativeDatetime(
   n: IntervalAmount,
-  unit?: string,
+  unit?: BucketName,
 ): string {
   return ML.describe_relative_datetime(n, unit);
 }
diff --git a/frontend/src/metabase/common/components/AggregationPicker/AggregationPicker.tsx b/frontend/src/metabase/common/components/AggregationPicker/AggregationPicker.tsx
index 57658185c44..3181318228a 100644
--- a/frontend/src/metabase/common/components/AggregationPicker/AggregationPicker.tsx
+++ b/frontend/src/metabase/common/components/AggregationPicker/AggregationPicker.tsx
@@ -4,6 +4,10 @@ import { t } from "ttag";
 import AccordionList from "metabase/core/components/AccordionList";
 import { useToggle } from "metabase/hooks/use-toggle";
 import { useSelector } from "metabase/lib/redux";
+import {
+  CompareAggregations,
+  getOffsetPeriod,
+} from "metabase/query_builder/components/CompareAggregations";
 import { ExpressionWidget } from "metabase/query_builder/components/expressions/ExpressionWidget";
 import { ExpressionWidgetHeader } from "metabase/query_builder/components/expressions/ExpressionWidgetHeader";
 import { getMetadata } from "metabase/selectors/metadata";
@@ -27,20 +31,29 @@ interface AggregationPickerProps {
   clauseIndex?: number;
   operators: Lib.AggregationOperator[];
   hasExpressionInput?: boolean;
-  onSelect: (operator: Lib.Aggregable) => void;
+  onAdd: (aggregations: Lib.Aggregable[]) => void;
+  onSelect: (aggregation: Lib.Aggregable) => void;
   onClose?: () => void;
 }
 
 type OperatorListItem = Lib.AggregationOperatorDisplayInfo & {
+  type: "operator";
   operator: Lib.AggregationOperator;
 };
 
 type MetricListItem = Lib.MetricDisplayInfo & {
+  type: "metric";
   metric: Lib.MetricMetadata;
   selected: boolean;
 };
 
-type ListItem = OperatorListItem | MetricListItem;
+type CompareListItem = {
+  type: "compare";
+  displayName: string;
+  selected?: boolean;
+};
+
+type ListItem = OperatorListItem | MetricListItem | CompareListItem;
 
 type Section = {
   name?: string;
@@ -50,10 +63,6 @@ type Section = {
   type?: string;
 };
 
-function isOperatorListItem(item: ListItem): item is OperatorListItem {
-  return "operator" in item;
-}
-
 export function AggregationPicker({
   className,
   query,
@@ -62,6 +71,7 @@ export function AggregationPicker({
   clauseIndex,
   operators,
   hasExpressionInput = true,
+  onAdd,
   onSelect,
   onClose,
 }: AggregationPickerProps) {
@@ -76,6 +86,7 @@ export function AggregationPicker({
   ] = useToggle(
     isExpressionEditorInitiallyOpen(query, stageIndex, clause, operators),
   );
+  const [isComparing, setIsComparing] = useState(false);
 
   // For really simple inline expressions like Average([Price]),
   // MLv2 can figure out that "Average" operator is used.
@@ -97,14 +108,17 @@ export function AggregationPicker({
     const database = metadata.database(databaseId);
     const canUseExpressions = database?.hasFeature("expression-aggregations");
     const isMetricBased = Lib.isMetricBased(query, stageIndex);
+    const compareItem = getCompareListItem(query, stageIndex);
+
+    if ((compareItem || operators.length > 0) && !isMetricBased) {
+      const operatorItems = operators.map(operator =>
+        getOperatorListItem(query, stageIndex, operator),
+      );
 
-    if (operators.length > 0 && !isMetricBased) {
       sections.push({
         key: "operators",
         name: t`Basic Metrics`,
-        items: operators.map(operator =>
-          getOperatorListItem(query, stageIndex, operator),
-        ),
+        items: compareItem ? [compareItem, ...operatorItems] : operatorItems,
         icon: "table2",
       });
     }
@@ -175,15 +189,25 @@ export function AggregationPicker({
     [onSelect, onClose],
   );
 
+  const handleCompareSelect = useCallback(() => {
+    setIsComparing(true);
+  }, []);
+
+  const handleCompareClose = useCallback(() => {
+    setIsComparing(false);
+  }, []);
+
   const handleChange = useCallback(
     (item: ListItem) => {
-      if (isOperatorListItem(item)) {
+      if (item.type === "operator") {
         handleOperatorSelect(item);
-      } else {
+      } else if (item.type === "metric") {
         handleMetricSelect(item);
+      } else if (item.type === "compare") {
+        handleCompareSelect();
       }
     },
-    [handleOperatorSelect, handleMetricSelect],
+    [handleOperatorSelect, handleMetricSelect, handleCompareSelect],
   );
 
   const handleSectionChange = useCallback(
@@ -204,6 +228,25 @@ export function AggregationPicker({
     [onSelect, onClose],
   );
 
+  const handleCompareSubmit = useCallback(
+    (aggregations: Lib.Aggregable[]) => {
+      onAdd(aggregations);
+      onClose?.();
+    },
+    [onAdd, onClose],
+  );
+
+  if (isComparing) {
+    return (
+      <CompareAggregations
+        query={query}
+        stageIndex={stageIndex}
+        onSubmit={handleCompareSubmit}
+        onClose={handleCompareClose}
+      />
+    );
+  }
+
   if (isEditingExpression) {
     return (
       <ExpressionWidget
@@ -330,6 +373,7 @@ function getOperatorListItem(
   const operatorInfo = Lib.displayInfo(query, stageIndex, operator);
   return {
     ...operatorInfo,
+    type: "operator",
     operator,
   };
 }
@@ -343,12 +387,41 @@ function getMetricListItem(
   const metricInfo = Lib.displayInfo(query, stageIndex, metric);
   return {
     ...metricInfo,
+    type: "metric",
     metric,
     selected:
       clauseIndex != null && metricInfo.aggregationPosition === clauseIndex,
   };
 }
 
+function getCompareListItem(
+  query: Lib.Query,
+  stageIndex: number,
+): CompareListItem | undefined {
+  const aggregations = Lib.aggregations(query, stageIndex);
+
+  if (aggregations.length === 0) {
+    return undefined;
+  }
+
+  const period = getOffsetPeriod(query, stageIndex);
+
+  if (aggregations.length > 1) {
+    return {
+      type: "compare",
+      displayName: t`Compare to previous ${period} ...`,
+    };
+  }
+
+  const [aggregation] = aggregations;
+  const info = Lib.displayInfo(query, stageIndex, aggregation);
+
+  return {
+    type: "compare",
+    displayName: t`Compare “${info.displayName}” to previous ${period} ...`,
+  };
+}
+
 function checkIsColumnSelected(columnInfo: Lib.ColumnDisplayInfo) {
   return !!columnInfo.selected;
 }
diff --git a/frontend/src/metabase/common/components/AggregationPicker/AggregationPicker.unit.spec.tsx b/frontend/src/metabase/common/components/AggregationPicker/AggregationPicker.unit.spec.tsx
index a132ccbed71..8c8398fbdd5 100644
--- a/frontend/src/metabase/common/components/AggregationPicker/AggregationPicker.unit.spec.tsx
+++ b/frontend/src/metabase/common/components/AggregationPicker/AggregationPicker.unit.spec.tsx
@@ -131,6 +131,7 @@ function setup({
     : baseOperators;
 
   const onSelect = jest.fn();
+  const onAdd = jest.fn();
 
   renderWithProviders(
     <AggregationPicker
@@ -139,6 +140,7 @@ function setup({
       stageIndex={stageIndex}
       operators={operators}
       hasExpressionInput={hasExpressionInput}
+      onAdd={onAdd}
       onSelect={onSelect}
     />,
     { storeInitialState: state },
diff --git a/frontend/src/metabase/query_builder/components/CompareAggregations/CompareAggregations.tsx b/frontend/src/metabase/query_builder/components/CompareAggregations/CompareAggregations.tsx
new file mode 100644
index 00000000000..bf5fcb099d2
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/CompareAggregations/CompareAggregations.tsx
@@ -0,0 +1,114 @@
+import type { FormEvent } from "react";
+import { useMemo, useState } from "react";
+import { t } from "ttag";
+
+import { Box, Button, Flex, Stack } from "metabase/ui";
+import * as Lib from "metabase-lib";
+
+import { ExpressionWidgetHeader } from "../expressions/ExpressionWidgetHeader";
+
+import {
+  ColumnPicker,
+  OffsetInput,
+  ReferenceAggregationPicker,
+} from "./components";
+import type { ColumnType } from "./types";
+import { canSubmit, getAggregations, getTitle } from "./utils";
+
+interface Props {
+  query: Lib.Query;
+  stageIndex: number;
+  onClose: () => void;
+  onSubmit: (aggregations: Lib.ExpressionClause[]) => void;
+}
+
+const DEFAULT_OFFSET = 1;
+const DEFAULT_COLUMNS: ColumnType[] = ["offset", "percent-diff-offset"];
+const STEP_1_WIDTH = 378;
+const STEP_2_WIDTH = 472;
+
+export const CompareAggregations = ({
+  query,
+  stageIndex,
+  onClose,
+  onSubmit,
+}: Props) => {
+  const aggregations = useMemo(() => {
+    return Lib.aggregations(query, stageIndex);
+  }, [query, stageIndex]);
+  const hasManyAggregations = aggregations.length > 1;
+  const [aggregation, setAggregation] = useState<
+    Lib.AggregationClause | Lib.ExpressionClause | undefined
+  >(hasManyAggregations ? undefined : aggregations[0]);
+  const [offset, setOffset] = useState<number | "">(DEFAULT_OFFSET);
+  const [columns, setColumns] = useState<ColumnType[]>(DEFAULT_COLUMNS);
+  const width = aggregation ? STEP_2_WIDTH : STEP_1_WIDTH;
+
+  const title = useMemo(
+    () => getTitle(query, stageIndex, aggregation),
+    [query, stageIndex, aggregation],
+  );
+
+  const handleBack = () => {
+    if (hasManyAggregations && aggregation) {
+      setAggregation(undefined);
+    } else {
+      onClose();
+    }
+  };
+
+  const handleSubmit = (event: FormEvent) => {
+    event.preventDefault();
+
+    if (aggregation && offset !== "") {
+      const aggregations = getAggregations(
+        query,
+        stageIndex,
+        aggregation,
+        columns,
+        offset,
+      );
+      onSubmit(aggregations);
+      onClose();
+    }
+  };
+
+  return (
+    <Box miw={width} maw={width}>
+      <ExpressionWidgetHeader title={title} onBack={handleBack} />
+
+      {!aggregation && (
+        <ReferenceAggregationPicker
+          query={query}
+          stageIndex={stageIndex}
+          onChange={setAggregation}
+        />
+      )}
+
+      {aggregation && (
+        <form onSubmit={handleSubmit}>
+          <Stack p="lg" spacing="xl">
+            <Stack spacing="md">
+              <OffsetInput
+                query={query}
+                stageIndex={stageIndex}
+                value={offset}
+                onChange={setOffset}
+              />
+
+              <ColumnPicker value={columns} onChange={setColumns} />
+            </Stack>
+
+            <Flex justify="flex-end">
+              <Button
+                disabled={!canSubmit(offset, columns)}
+                type="submit"
+                variant="filled"
+              >{t`Done`}</Button>
+            </Flex>
+          </Stack>
+        </form>
+      )}
+    </Box>
+  );
+};
diff --git a/frontend/src/metabase/query_builder/components/CompareAggregations/components/ColumnPicker/ColumnPicker.module.css b/frontend/src/metabase/query_builder/components/CompareAggregations/components/ColumnPicker/ColumnPicker.module.css
new file mode 100644
index 00000000000..70e0cab0b40
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/CompareAggregations/components/ColumnPicker/ColumnPicker.module.css
@@ -0,0 +1,3 @@
+.itemContent {
+  flex: 1;
+}
diff --git a/frontend/src/metabase/query_builder/components/CompareAggregations/components/ColumnPicker/ColumnPicker.tsx b/frontend/src/metabase/query_builder/components/CompareAggregations/components/ColumnPicker/ColumnPicker.tsx
new file mode 100644
index 00000000000..0c680c7fc0d
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/CompareAggregations/components/ColumnPicker/ColumnPicker.tsx
@@ -0,0 +1,95 @@
+import type { ComponentPropsWithoutRef } from "react";
+import { forwardRef, useCallback } from "react";
+import { t } from "ttag";
+
+import { Checkbox, Flex, MultiSelect, Text } from "metabase/ui";
+
+import type { ColumnType } from "../../types";
+
+import S from "./ColumnPicker.module.css";
+
+interface ItemType {
+  example: string;
+  label: string;
+  value: ColumnType;
+}
+
+interface Props {
+  value: ColumnType[];
+  onChange: (value: ColumnType[]) => void;
+}
+
+const COLUMN_OPTIONS: ItemType[] = [
+  {
+    example: "1826, 3004",
+    label: t`Previous value`,
+    value: "offset",
+  },
+  {
+    example: "+2.3%, -0.1%",
+    label: t`Percentage difference`,
+    value: "percent-diff-offset",
+  },
+  {
+    example: "+42, -3",
+    label: t`Value difference`,
+    value: "diff-offset",
+  },
+];
+
+export const ColumnPicker = ({ value, onChange }: Props) => {
+  const handleChange = useCallback(
+    (values: string[]) => {
+      onChange(values as ColumnType[]);
+    },
+    [onChange],
+  );
+
+  return (
+    <MultiSelect
+      data={COLUMN_OPTIONS}
+      disableSelectedItemFiltering
+      itemComponent={Item}
+      label={t`Columns to create`}
+      placeholder={t`Columns to create`}
+      styles={{
+        item: {
+          "&[data-selected]": {
+            backgroundColor: "transparent",
+          },
+          "&[data-selected]:hover": {
+            backgroundColor: "var(--mb-color-brand-lighter)",
+          },
+          "&[data-selected][data-hovered]": {
+            backgroundColor: "var(--mb-color-brand-lighter)",
+          },
+          "&[data-hovered]": {
+            backgroundColor: "var(--mb-color-brand-lighter)",
+          },
+        },
+      }}
+      value={value}
+      onChange={handleChange}
+    />
+  );
+};
+
+const Item = forwardRef<
+  HTMLDivElement,
+  ItemType & ComponentPropsWithoutRef<"div"> & { selected: boolean }
+>(function Item({ example, label, selected, value, ...props }, ref) {
+  return (
+    <div ref={ref} {...props}>
+      <Flex align="center" gap="sm">
+        <Checkbox checked={selected} readOnly />
+
+        <Flex align="center" className={S.itemContent} justify="space-between">
+          <Text>{label}</Text>
+          <Text c="text-light" size="sm">
+            {example}
+          </Text>
+        </Flex>
+      </Flex>
+    </div>
+  );
+});
diff --git a/frontend/src/metabase/query_builder/components/CompareAggregations/components/ColumnPicker/index.ts b/frontend/src/metabase/query_builder/components/CompareAggregations/components/ColumnPicker/index.ts
new file mode 100644
index 00000000000..9e32cfe25f8
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/CompareAggregations/components/ColumnPicker/index.ts
@@ -0,0 +1 @@
+export * from "./ColumnPicker";
diff --git a/frontend/src/metabase/query_builder/components/CompareAggregations/components/OffsetInput/OffsetInput.module.css b/frontend/src/metabase/query_builder/components/CompareAggregations/components/OffsetInput/OffsetInput.module.css
new file mode 100644
index 00000000000..f3dbdd8e908
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/CompareAggregations/components/OffsetInput/OffsetInput.module.css
@@ -0,0 +1,12 @@
+.input {
+  max-width: 50px;
+}
+
+.wrapper {
+  margin-top: 0.25rem;
+}
+
+.help {
+  position: absolute;
+  left: 50px;
+}
diff --git a/frontend/src/metabase/query_builder/components/CompareAggregations/components/OffsetInput/OffsetInput.tsx b/frontend/src/metabase/query_builder/components/CompareAggregations/components/OffsetInput/OffsetInput.tsx
new file mode 100644
index 00000000000..24b90fec8d2
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/CompareAggregations/components/OffsetInput/OffsetInput.tsx
@@ -0,0 +1,52 @@
+import { useCallback, useMemo } from "react";
+
+import { Flex, NumberInput, Text } from "metabase/ui";
+import type * as Lib from "metabase-lib";
+
+import S from "./OffsetInput.module.css";
+import { getHelp, getLabel } from "./utils";
+
+interface Props {
+  query: Lib.Query;
+  stageIndex: number;
+  value: number | "";
+  onChange: (value: number | "") => void;
+}
+
+export const OffsetInput = ({ query, stageIndex, value, onChange }: Props) => {
+  const label = useMemo(() => getLabel(query, stageIndex), [query, stageIndex]);
+  const help = useMemo(() => getHelp(query, stageIndex), [query, stageIndex]);
+
+  const handleChange = useCallback(
+    (value: number | "") => {
+      if (typeof value === "number") {
+        onChange(Math.floor(Math.max(Math.abs(value), 1)));
+      } else {
+        onChange(value);
+      }
+    },
+    [onChange],
+  );
+
+  return (
+    <Flex align="flex-end" pos="relative">
+      <NumberInput
+        classNames={{
+          input: S.input,
+          wrapper: S.wrapper,
+        }}
+        label={label}
+        min={1}
+        precision={0}
+        size="md"
+        step={1}
+        type="number"
+        value={value}
+        onChange={handleChange}
+      />
+      <Text className={S.help} c="text-light" p="sm">
+        {help}
+      </Text>
+    </Flex>
+  );
+};
diff --git a/frontend/src/metabase/query_builder/components/CompareAggregations/components/OffsetInput/index.ts b/frontend/src/metabase/query_builder/components/CompareAggregations/components/OffsetInput/index.ts
new file mode 100644
index 00000000000..336cbf1a199
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/CompareAggregations/components/OffsetInput/index.ts
@@ -0,0 +1 @@
+export * from "./OffsetInput";
diff --git a/frontend/src/metabase/query_builder/components/CompareAggregations/components/OffsetInput/utils.ts b/frontend/src/metabase/query_builder/components/CompareAggregations/components/OffsetInput/utils.ts
new file mode 100644
index 00000000000..7e8e7fbba3c
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/CompareAggregations/components/OffsetInput/utils.ts
@@ -0,0 +1,58 @@
+import { t } from "ttag";
+
+import * as Lib from "metabase-lib";
+
+export const getLabel = (query: Lib.Query, stageIndex: number): string => {
+  const firstBreakout = Lib.breakouts(query, stageIndex)[0];
+
+  if (firstBreakout) {
+    const firstBreakoutColumn = Lib.breakoutColumn(
+      query,
+      stageIndex,
+      firstBreakout,
+    );
+
+    if (!Lib.isDate(firstBreakoutColumn)) {
+      return t`Row for comparison`;
+    }
+  }
+
+  return t`Previous period`;
+};
+
+export const getHelp = (query: Lib.Query, stageIndex: number): string => {
+  const firstBreakout = Lib.breakouts(query, stageIndex)[0];
+
+  if (!firstBreakout) {
+    return t`periods ago based on grouping`;
+  }
+
+  const firstBreakoutColumn = Lib.breakoutColumn(
+    query,
+    stageIndex,
+    firstBreakout,
+  );
+  const firstBreakoutColumnInfo = Lib.displayInfo(
+    query,
+    stageIndex,
+    firstBreakoutColumn,
+  );
+
+  if (!Lib.isDate(firstBreakoutColumn)) {
+    return t`rows above based on “${firstBreakoutColumnInfo.displayName}”`;
+  }
+
+  const bucket = Lib.temporalBucket(firstBreakout);
+
+  if (!bucket) {
+    return t`periods ago based on “${firstBreakoutColumnInfo.displayName}”`;
+  }
+
+  const bucketInfo = Lib.displayInfo(query, stageIndex, bucket);
+  const periodPlural = Lib.describeTemporalUnit(
+    bucketInfo.shortName,
+    2,
+  ).toLowerCase();
+
+  return t`${periodPlural} ago based on “${firstBreakoutColumnInfo.displayName}”`;
+};
diff --git a/frontend/src/metabase/query_builder/components/CompareAggregations/components/ReferenceAggregationPicker/ReferenceAggregationPicker.module.css b/frontend/src/metabase/query_builder/components/CompareAggregations/components/ReferenceAggregationPicker/ReferenceAggregationPicker.module.css
new file mode 100644
index 00000000000..e30c401a0ea
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/CompareAggregations/components/ReferenceAggregationPicker/ReferenceAggregationPicker.module.css
@@ -0,0 +1,3 @@
+.accordionList {
+  color: var(--mb-color-summarize);
+}
diff --git a/frontend/src/metabase/query_builder/components/CompareAggregations/components/ReferenceAggregationPicker/ReferenceAggregationPicker.tsx b/frontend/src/metabase/query_builder/components/CompareAggregations/components/ReferenceAggregationPicker/ReferenceAggregationPicker.tsx
new file mode 100644
index 00000000000..1a27cb4d0a5
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/CompareAggregations/components/ReferenceAggregationPicker/ReferenceAggregationPicker.tsx
@@ -0,0 +1,61 @@
+import { useCallback, useMemo } from "react";
+
+import AccordionList from "metabase/core/components/AccordionList";
+import * as Lib from "metabase-lib";
+
+import S from "./ReferenceAggregationPicker.module.css";
+
+type AggregationItem = Lib.AggregationClauseDisplayInfo & {
+  aggregation: Lib.AggregationClause;
+};
+
+interface Props {
+  query: Lib.Query;
+  stageIndex: number;
+  onChange: (aggregation: Lib.AggregationClause | Lib.ExpressionClause) => void;
+}
+
+export const ReferenceAggregationPicker = ({
+  query,
+  stageIndex,
+  onChange,
+}: Props) => {
+  const sections = useMemo(
+    () => getSections(query, stageIndex),
+    [query, stageIndex],
+  );
+
+  const handleChange = useCallback(
+    (item: AggregationItem) => {
+      onChange(item.aggregation);
+    },
+    [onChange],
+  );
+
+  return (
+    <AccordionList
+      alwaysExpanded
+      className={S.accordionList}
+      maxHeight={Infinity}
+      renderItemDescription={renderItemDescription}
+      renderItemName={renderItemName}
+      sections={sections}
+      width="100%"
+      onChange={handleChange}
+    />
+  );
+};
+
+const renderItemName = (item: AggregationItem) => item.displayName;
+
+const renderItemDescription = () => null;
+
+const getSections = (query: Lib.Query, stageIndex: number) => {
+  const aggregations = Lib.aggregations(query, stageIndex);
+  const items = aggregations.map<AggregationItem>(aggregation => {
+    const info = Lib.displayInfo(query, stageIndex, aggregation);
+    return { ...info, aggregation };
+  });
+  const sections = [{ items }];
+  return sections;
+};
diff --git a/frontend/src/metabase/query_builder/components/CompareAggregations/components/ReferenceAggregationPicker/index.ts b/frontend/src/metabase/query_builder/components/CompareAggregations/components/ReferenceAggregationPicker/index.ts
new file mode 100644
index 00000000000..01641f63a32
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/CompareAggregations/components/ReferenceAggregationPicker/index.ts
@@ -0,0 +1 @@
+export * from "./ReferenceAggregationPicker";
diff --git a/frontend/src/metabase/query_builder/components/CompareAggregations/components/index.ts b/frontend/src/metabase/query_builder/components/CompareAggregations/components/index.ts
new file mode 100644
index 00000000000..f9448e4a379
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/CompareAggregations/components/index.ts
@@ -0,0 +1,3 @@
+export * from "./ColumnPicker";
+export * from "./OffsetInput";
+export * from "./ReferenceAggregationPicker";
diff --git a/frontend/src/metabase/query_builder/components/CompareAggregations/index.ts b/frontend/src/metabase/query_builder/components/CompareAggregations/index.ts
new file mode 100644
index 00000000000..618449483b1
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/CompareAggregations/index.ts
@@ -0,0 +1,2 @@
+export * from "./CompareAggregations";
+export * from "./utils";
diff --git a/frontend/src/metabase/query_builder/components/CompareAggregations/types.ts b/frontend/src/metabase/query_builder/components/CompareAggregations/types.ts
new file mode 100644
index 00000000000..2577e73cd5e
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/CompareAggregations/types.ts
@@ -0,0 +1 @@
+export type ColumnType = "offset" | "diff-offset" | "percent-diff-offset";
diff --git a/frontend/src/metabase/query_builder/components/CompareAggregations/utils.ts b/frontend/src/metabase/query_builder/components/CompareAggregations/utils.ts
new file mode 100644
index 00000000000..d9221516265
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/CompareAggregations/utils.ts
@@ -0,0 +1,95 @@
+import { t } from "ttag";
+
+import * as Lib from "metabase-lib";
+
+import type { ColumnType } from "./types";
+
+export const getOffsetPeriod = (
+  query: Lib.Query,
+  stageIndex: number,
+): string => {
+  const firstBreakout = Lib.breakouts(query, stageIndex)[0];
+
+  if (!firstBreakout) {
+    return t`period`;
+  }
+
+  const firstBreakoutColumn = Lib.breakoutColumn(
+    query,
+    stageIndex,
+    firstBreakout,
+  );
+
+  if (!Lib.isDate(firstBreakoutColumn)) {
+    return t`rows`;
+  }
+
+  const bucket = Lib.temporalBucket(firstBreakout);
+
+  if (!bucket) {
+    return t`period`;
+  }
+
+  const bucketInfo = Lib.displayInfo(query, stageIndex, bucket);
+  const periodPlural = Lib.describeTemporalUnit(
+    bucketInfo.shortName,
+    2,
+  ).toLowerCase();
+
+  return periodPlural;
+};
+
+export const getTitle = (
+  query: Lib.Query,
+  stageIndex: number,
+  aggregation: Lib.AggregationClause | Lib.ExpressionClause | undefined,
+): string => {
+  const period = getOffsetPeriod(query, stageIndex);
+
+  if (!aggregation) {
+    return t`Compare one of these to the previous ${period}`;
+  }
+
+  const info = Lib.displayInfo(query, stageIndex, aggregation);
+
+  return t`Compare “${info.displayName}” to previous ${period}`;
+};
+
+export const getAggregations = (
+  query: Lib.Query,
+  stageIndex: number,
+  aggregation: Lib.AggregationClause | Lib.ExpressionClause,
+  columns: ColumnType[],
+  offset: number,
+): Lib.ExpressionClause[] => {
+  const aggregations: Lib.ExpressionClause[] = [];
+
+  if (columns.includes("offset")) {
+    aggregations.push(
+      Lib.offsetClause(query, stageIndex, aggregation, -offset),
+    );
+  }
+
+  if (columns.includes("diff-offset")) {
+    aggregations.push(
+      Lib.diffOffsetClause(query, stageIndex, aggregation, -offset),
+    );
+  }
+
+  if (columns.includes("percent-diff-offset")) {
+    aggregations.push(
+      Lib.percentDiffOffsetClause(query, stageIndex, aggregation, -offset),
+    );
+  }
+
+  return aggregations;
+};
+
+export const canSubmit = (
+  period: number | "",
+  columns: ColumnType[],
+): boolean => {
+  const isPeriodValid = typeof period === "number" && period > 0;
+  const areColumnsValid = columns.length > 0;
+  return isPeriodValid && areColumnsValid;
+};
diff --git a/frontend/src/metabase/query_builder/components/notebook/steps/AggregateStep/AggregateStep.tsx b/frontend/src/metabase/query_builder/components/notebook/steps/AggregateStep/AggregateStep.tsx
index b248cbbfacc..d7267211f7a 100644
--- a/frontend/src/metabase/query_builder/components/notebook/steps/AggregateStep/AggregateStep.tsx
+++ b/frontend/src/metabase/query_builder/components/notebook/steps/AggregateStep/AggregateStep.tsx
@@ -21,8 +21,11 @@ export function AggregateStep({
     return Lib.aggregations(query, stageIndex);
   }, [query, stageIndex]);
 
-  const handleAddAggregation = (aggregation: Lib.Aggregable) => {
-    const nextQuery = Lib.aggregate(query, stageIndex, aggregation);
+  const handleAddAggregations = (aggregations: Lib.Aggregable[]) => {
+    const nextQuery = aggregations.reduce(
+      (query, aggregation) => Lib.aggregate(query, stageIndex, aggregation),
+      query,
+    );
     updateQuery(nextQuery);
   };
 
@@ -74,7 +77,7 @@ export function AggregateStep({
           stageIndex={stageIndex}
           clause={aggregation}
           clauseIndex={index}
-          onAddAggregation={handleAddAggregation}
+          onAddAggregations={handleAddAggregations}
           onUpdateAggregation={handleUpdateAggregation}
           onClose={onClose}
         />
@@ -94,7 +97,7 @@ interface AggregationPopoverProps {
     currentClause: Lib.AggregationClause,
     nextClause: Lib.Aggregable,
   ) => void;
-  onAddAggregation: (aggregation: Lib.Aggregable) => void;
+  onAddAggregations: (aggregations: Lib.Aggregable[]) => void;
 
   clauseIndex?: number;
 
@@ -106,7 +109,7 @@ function AggregationPopover({
   stageIndex,
   clause,
   clauseIndex,
-  onAddAggregation,
+  onAddAggregations,
   onUpdateAggregation,
   onClose,
 }: AggregationPopoverProps) {
@@ -126,11 +129,12 @@ function AggregationPopover({
       clause={clause}
       clauseIndex={clauseIndex}
       operators={operators}
+      onAdd={onAddAggregations}
       onSelect={aggregation => {
         if (isUpdate) {
           onUpdateAggregation(clause, aggregation);
         } else {
-          onAddAggregation(aggregation);
+          onAddAggregations([aggregation]);
         }
       }}
       onClose={onClose}
diff --git a/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/AddAggregationButton/AddAggregationButton.tsx b/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/AddAggregationButton/AddAggregationButton.tsx
index c2e3a1e9a66..14244f74eed 100644
--- a/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/AddAggregationButton/AddAggregationButton.tsx
+++ b/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/AddAggregationButton/AddAggregationButton.tsx
@@ -13,12 +13,12 @@ const STAGE_INDEX = -1;
 
 interface AddAggregationButtonProps {
   query: Lib.Query;
-  onAddAggregation: (aggregation: Lib.Aggregable) => void;
+  onAddAggregations: (aggregation: Lib.Aggregable[]) => void;
 }
 
 export function AddAggregationButton({
   query,
-  onAddAggregation,
+  onAddAggregations,
 }: AddAggregationButtonProps) {
   const [isOpened, setIsOpened] = useState(false);
   const hasAggregations = Lib.aggregations(query, STAGE_INDEX).length > 0;
@@ -53,8 +53,12 @@ export function AddAggregationButton({
           stageIndex={STAGE_INDEX}
           operators={operators}
           hasExpressionInput={false}
+          onAdd={aggregations => {
+            onAddAggregations(aggregations);
+            setIsOpened(false);
+          }}
           onSelect={aggregation => {
-            onAddAggregation(aggregation);
+            onAddAggregations([aggregation]);
             setIsOpened(false);
           }}
         />
diff --git a/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/AggregationItem/AggregationItem.tsx b/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/AggregationItem/AggregationItem.tsx
index 693b64917cf..787694324d1 100644
--- a/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/AggregationItem/AggregationItem.tsx
+++ b/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/AggregationItem/AggregationItem.tsx
@@ -13,6 +13,7 @@ interface AggregationItemProps {
   query: Lib.Query;
   aggregation: Lib.AggregationClause;
   aggregationIndex: number;
+  onAdd: (aggregations: Lib.Aggregable[]) => void;
   onUpdate: (nextAggregation: Lib.Aggregable) => void;
   onRemove: () => void;
 }
@@ -21,6 +22,7 @@ export function AggregationItem({
   query,
   aggregation,
   aggregationIndex,
+  onAdd,
   onUpdate,
   onRemove,
 }: AggregationItemProps) {
@@ -52,6 +54,7 @@ export function AggregationItem({
           clauseIndex={aggregationIndex}
           operators={operators}
           hasExpressionInput={false}
+          onAdd={onAdd}
           onSelect={nextAggregation => {
             onUpdate(nextAggregation);
             setIsOpened(false);
diff --git a/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/SummarizeSidebar.tsx b/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/SummarizeSidebar.tsx
index 3e2478da953..400ac4dba79 100644
--- a/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/SummarizeSidebar.tsx
+++ b/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/SummarizeSidebar.tsx
@@ -40,9 +40,12 @@ export function SummarizeSidebar({
   const aggregations = Lib.aggregations(query, STAGE_INDEX);
   const hasAggregations = aggregations.length > 0;
 
-  const handleAddAggregation = useCallback(
-    (aggregation: Lib.Aggregable) => {
-      const nextQuery = Lib.aggregate(query, STAGE_INDEX, aggregation);
+  const handleAddAggregations = useCallback(
+    (aggregations: Lib.Aggregable[]) => {
+      const nextQuery = aggregations.reduce(
+        (query, aggregation) => Lib.aggregate(query, STAGE_INDEX, aggregation),
+        query,
+      );
       onQueryChange(nextQuery);
     },
     [query, onQueryChange],
@@ -131,6 +134,7 @@ export function SummarizeSidebar({
             query={query}
             aggregation={aggregation}
             aggregationIndex={aggregationIndex}
+            onAdd={handleAddAggregations}
             onUpdate={nextAggregation =>
               handleUpdateAggregation(aggregation, nextAggregation)
             }
@@ -139,7 +143,7 @@ export function SummarizeSidebar({
         ))}
         <AddAggregationButton
           query={query}
-          onAddAggregation={handleAddAggregation}
+          onAddAggregations={handleAddAggregations}
         />
       </AggregationsContainer>
       {hasAggregations && (
diff --git a/frontend/src/metabase/styled-components/containers/GlobalStyles/GlobalStyles.tsx b/frontend/src/metabase/styled-components/containers/GlobalStyles/GlobalStyles.tsx
index 9ee47732393..ea85d58df1a 100644
--- a/frontend/src/metabase/styled-components/containers/GlobalStyles/GlobalStyles.tsx
+++ b/frontend/src/metabase/styled-components/containers/GlobalStyles/GlobalStyles.tsx
@@ -2,7 +2,7 @@ import { css, Global, useTheme } from "@emotion/react";
 
 import { baseStyle, getRootStyle } from "metabase/css/core/base.styled";
 import { defaultFontFiles } from "metabase/css/core/fonts.styled";
-import { alpha, color } from "metabase/lib/colors";
+import { alpha, color, lighten } from "metabase/lib/colors";
 import { getSitePath } from "metabase/lib/dom";
 import { useSelector } from "metabase/lib/redux";
 import { aceEditorStyles } from "metabase/query_builder/components/NativeQueryEditor/NativeQueryEditor.styled";
@@ -23,6 +23,7 @@ export const GlobalStyles = (): JSX.Element => {
       --mb-color-brand: ${color("brand")};
       --mb-color-brand-alpha-04: ${alpha("brand", 0.04)};
       --mb-color-brand-alpha-88: ${alpha("brand", 0.88)};
+      --mb-color-brand-lighter: ${lighten("brand", 0.598)};
       --mb-color-focus: ${color("focus")};
       --mb-color-bg-dark: ${color("bg-dark")};
       --mb-color-bg-light: ${color("bg-light")};
-- 
GitLab